🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-29 19:24:20 +08:00
parent 8d9bcdad7e
commit 3521e5787b
20 changed files with 1463 additions and 1149 deletions

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) => {
try {
const result = adjuster();
if (result && typeof (result as Promise<void>).then === 'function') {
(result as Promise<void>).catch((error) => {
console.error('Failed to adjust font size:', error);
});
}
} catch (error) {
console.error('Failed to adjust font size:', error);
}
};
export interface WheelZoomOptions {
/** 增加字体大小的回调(立即执行) */
increaseFontSize: FontAdjuster;
/** 减少字体大小的回调(立即执行) */
decreaseFontSize: FontAdjuster;
/** 保存回调(防抖执行),在滚动结束后调用 */
onSave?: SaveCallback;
/** 保存防抖延迟(毫秒),默认 300ms */
saveDelay?: number;
}
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
// 如果有 onSave 回调,创建防抖版本
const {debouncedFn: debouncedSave} = onSave
? createDebounce(() => {
try {
const result = onSave();
if (result && typeof (result as Promise<void>).then === 'function') {
(result as Promise<void>).catch((error) => {
console.error('Failed to save font size:', error);
});
}
} catch (error) {
console.error('Failed to save font size:', error);
}
}, {delay: saveDelay})
: {debouncedFn: null};
export const createWheelZoomExtension = (
increaseFontSize: FontAdjuster,
decreaseFontSize: FontAdjuster
): Extension => {
return EditorView.domEventHandlers({
wheel(event) {
if (!event.ctrlKey) {
@@ -28,10 +43,16 @@ export const createWheelZoomExtension = (
event.preventDefault();
// 立即更新字体大小
if (event.deltaY < 0) {
runAdjuster(increaseFontSize);
increaseFontSize();
} else if (event.deltaY > 0) {
runAdjuster(decreaseFontSize);
decreaseFontSize();
}
// 防抖保存
if (debouncedSave) {
debouncedSave();
}
return true;

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) {
@@ -151,7 +158,7 @@ const blockLayer = layer({
// 让最后一个块直接延伸到编辑器底部
if (contentBottom < editorHeight) {
const extraHeight = editorHeight - contentBottom-10;
const extraHeight = editorHeight - contentBottom - 10;
toCoordsBottom += extraHeight;
}
}

View File

@@ -3,7 +3,7 @@ import { blockquote } from './plugins/blockquote';
import { codeblock } from './plugins/code-block';
import { headings } from './plugins/heading';
import { hideMarks } from './plugins/hide-mark';
import { htmlBlock } from './plugins/html';
import { htmlBlockExtension } from './plugins/html';
import { image } from './plugins/image';
import { links } from './plugins/link';
import { lists } from './plugins/list';
@@ -14,7 +14,6 @@ import { imagePreview } from './state/image';
import { codeblockEnhanced } from './plugins/code-block-enhanced';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { softIndent } from './plugins/soft-indent';
import { revealOnArrow } from './plugins/reveal-on-arrow';
import { pasteRichText } from './plugins/paste-rich-text';
@@ -29,7 +28,7 @@ export { frontmatter } from './plugins/frontmatter';
export { headings } from './plugins/heading';
export { hideMarks } from './plugins/hide-mark';
export { image } from './plugins/image';
export { htmlBlock } from './plugins/html';
export { htmlBlock, htmlBlockExtension } from './plugins/html';
export { links } from './plugins/link';
export { lists } from './plugins/list';
@@ -37,7 +36,6 @@ export { lists } from './plugins/list';
export { codeblockEnhanced } from './plugins/code-block-enhanced';
export { emoji, addEmoji, getEmojiNames } from './plugins/emoji';
export { horizontalRule } from './plugins/horizontal-rule';
export { softIndent } from './plugins/soft-indent';
export { revealOnArrow } from './plugins/reveal-on-arrow';
export { pasteRichText } from './plugins/paste-rich-text';
@@ -47,6 +45,8 @@ export * as classes from './classes';
/**
* markdown extensions (includes all ProseMark-inspired features).
* NOTE: All decorations avoid using block: true to prevent interfering
* with the codeblock system's boundary calculations.
*/
export const markdownExtensions: Extension = [
headingSlugField,
@@ -58,12 +58,11 @@ export const markdownExtensions: Extension = [
lists(),
links(),
image(),
htmlBlock,
htmlBlockExtension,
// Enhanced features
codeblockEnhanced(),
emoji(),
horizontalRule(),
softIndent(),
revealOnArrow(),
pasteRichText()
];

View File

@@ -3,101 +3,87 @@ import {
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
ViewUpdate
} from '@codemirror/view';
import { Range } from '@codemirror/state';
import {
iterateTreeInVisibleRanges,
editorLines,
isCursorInRange,
checkRangeSubset
} from '../util';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange, invisibleDecoration } from '../util';
import { blockquote as classes } from '../classes';
const quoteMarkRE = /^(\s*>+)/gm;
class BlockQuoteBorderWidget extends WidgetType {
toDOM(): HTMLElement {
const dom = document.createElement('span');
dom.classList.add(classes.mark);
return dom;
}
/**
* Blockquote plugin.
*
* Features:
* - Decorates blockquote with left border
* - Hides quote marks (>) when cursor is outside
* - Supports nested blockquotes
*/
export function blockquote() {
return [blockQuotePlugin, baseTheme];
}
/**
* Plugin to add style blockquotes.
* 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]);
// 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: classes.widget }).range(line.from)
);
}
}
// Hide quote marks when cursor is outside
if (!cursorInBlockquote) {
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 = this.styleBlockquote(view);
this.decorations = buildBlockQuoteDecorations(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
) {
this.decorations = this.styleBlockquote(update.view);
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildBlockQuoteDecorations(update.view);
}
}
/**
*
* @param view - The editor view
* @returns The blockquote decorations to add to the editor
*/
private styleBlockquote(view: EditorView): DecorationSet {
const widgets: Range<Decoration>[] = [];
iterateTreeInVisibleRanges(view, {
enter: ({ name, from, to }) => {
if (name !== 'Blockquote') return;
const lines = editorLines(view, from, to);
lines.forEach((line) => {
const lineDec = Decoration.line({
class: classes.widget
});
widgets.push(lineDec.range(line.from));
});
if (
lines.every(
(line) =>
!isCursorInRange(view.state, [line.from, line.to])
)
) {
const marks = Array.from(
view.state.sliceDoc(from, to).matchAll(quoteMarkRE)
)
.map((x) => from + x.index)
.map((i) =>
Decoration.replace({
widget: new BlockQuoteBorderWidget()
}).range(i, i + 1)
);
lines.forEach((line) => {
if (
!marks.some((mark) =>
checkRangeSubset(
[line.from, line.to],
[mark.from, mark.to]
)
)
)
marks.push(
Decoration.widget({
widget: new BlockQuoteBorderWidget()
}).range(line.from)
);
});
widgets.push(...marks);
}
}
});
return Decoration.set(widgets, true);
}
}
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
@@ -105,24 +91,11 @@ const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
});
/**
* Default styles for blockquotes.
* Base theme for blockquotes.
*/
const baseTheme = EditorView.baseTheme({
['.' + classes.mark]: {
'border-left': '4px solid #ccc'
},
['.' + classes.widget]: {
color: '#555'
[`.${classes.widget}`]: {
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
color: 'var(--cm-blockquote-color, #666)'
}
});
/**
* Ixora blockquote plugin.
*
* This plugin allows to:
* - Decorate blockquote marks in the editor
* - Add default styling to blockquote marks
*/
export function blockquote() {
return [blockQuotePlugin, baseTheme];
}

View File

@@ -1,4 +1,4 @@
import { Extension } from '@codemirror/state';
import { Extension, Range } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
@@ -6,92 +6,148 @@ import {
EditorView,
ViewUpdate
} from '@codemirror/view';
import {
isCursorInRange,
invisibleDecoration,
iterateTreeInVisibleRanges,
editorLines
} from '../util';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
import { codeblock as classes } from '../classes';
/**
* Ixora code block plugin.
* Code block types to match in the syntax tree.
*/
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
/**
* Code block plugin with optimized decoration building.
*
* This plugin allows to:
* - Add default styling to code blocks
* - Customize visibility of code block markers and language
* This plugin:
* - Adds styling to code blocks (begin/end markers)
* - Hides code markers and language info when cursor is outside
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
const codeBlockPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorateCodeBlocks(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
)
this.decorations = decorateCodeBlocks(update.view);
}
},
{ decorations: (v) => v.decorations }
);
/**
* Build code block decorations.
* Uses array + Decoration.set() for automatic sorting.
*/
function buildCodeBlockDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const visited = new Set<string>();
function decorateCodeBlocks(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (!['FencedCode', 'CodeBlock'].includes(type.name)) return;
editorLines(view, from, to).forEach((block, i) => {
const lineDec = Decoration.line({
class: [
classes.widget,
i === 0
? classes.widgetBegin
: block.to === to
? classes.widgetEnd
: ''
].join(' ')
});
widgets.push(lineDec.range(block.from));
});
if (isCursorInRange(view.state, [from, to])) return;
const codeBlock = node.toTree();
codeBlock.iterate({
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
switch (type.name) {
case 'CodeInfo':
case 'CodeMark':
// eslint-disable-next-line no-case-declarations
const decRange = invisibleDecoration.range(
from + nodeFrom,
from + nodeTo
);
widgets.push(decRange);
break;
}
// Process only visible ranges
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 typeof CODE_BLOCK_TYPES[number])) {
return;
}
});
}
});
return Decoration.set(widgets, true);
// Avoid processing the same code block multiple times
const key = `${nodeFrom}:${nodeTo}`;
if (visited.has(key)) return;
visited.add(key);
const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Add line decorations for each line in the code block
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
// Determine line position class
let positionClass = '';
if (lineNum === startLine.number) {
positionClass = classes.widgetBegin;
} else if (lineNum === endLine.number) {
positionClass = classes.widgetEnd;
}
decorations.push(
Decoration.line({
class: `${classes.widget} ${positionClass}`.trim()
}).range(line.from)
);
}
// Hide code markers when cursor is outside the block
if (!cursorInBlock) {
const codeBlock = node.toTree();
codeBlock.iterate({
enter: ({ type: childType, from: childFrom, to: childTo }) => {
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
decorations.push(
Decoration.replace({}).range(
nodeFrom + childFrom,
nodeFrom + childTo
)
);
}
}
});
}
}
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
}
/**
* Base theme for code block plugin.
* Code block plugin class with optimized update detection.
*/
class CodeBlockPlugin {
decorations: DecorationSet;
private lastSelection: number = -1;
constructor(view: EditorView) {
this.decorations = buildCodeBlockDecorations(view);
this.lastSelection = view.state.selection.main.head;
}
update(update: ViewUpdate) {
const docChanged = update.docChanged;
const viewportChanged = update.viewportChanged;
const selectionChanged = update.selectionSet;
// Optimization: check if selection moved to a different line
if (selectionChanged && !docChanged && !viewportChanged) {
const newHead = update.state.selection.main.head;
const oldHead = this.lastSelection;
const oldLine = update.startState.doc.lineAt(oldHead);
const newLine = update.state.doc.lineAt(newHead);
if (oldLine.number === newLine.number) {
this.lastSelection = newHead;
return;
}
}
if (docChanged || viewportChanged || selectionChanged) {
this.decorations = buildCodeBlockDecorations(update.view);
this.lastSelection = update.state.selection.main.head;
}
}
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for code blocks.
*/
const baseTheme = EditorView.baseTheme({
['.' + classes.widget]: {
backgroundColor: '#CCC7'
[`.${classes.widget}`]: {
backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))'
},
['.' + classes.widgetBegin]: {
[`.${classes.widgetBegin}`]: {
borderRadius: '5px 5px 0 0'
},
['.' + classes.widgetEnd]: {
[`.${classes.widgetEnd}`]: {
borderRadius: '0 0 5px 5px'
}
});

View File

@@ -1,4 +1,4 @@
import { Extension } from '@codemirror/state';
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
@@ -7,138 +7,156 @@ import {
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange, iterateTreeInVisibleRanges } from '../util';
import { isCursorInRange } from '../util';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
*
* This plugin:
* 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
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/**
* Common emoji mappings.
* Extended from common emoji shortcodes.
* Emoji regex pattern for matching :emoji_name: syntax.
*/
const EMOJI_MAP: { [key: string]: string } = {
// Smileys & Emotion
smile: '😄',
smiley: '😃',
grin: '😁',
laughing: '😆',
satisfied: '😆',
sweat_smile: '😅',
rofl: '🤣',
joy: '😂',
slightly_smiling_face: '🙂',
upside_down_face: '🙃',
wink: '😉',
blush: '😊',
innocent: '😇',
smiling_face_with_three_hearts: '🥰',
heart_eyes: '😍',
star_struck: '🤩',
kissing_heart: '😘',
kissing: '😗',
relaxed: '☺️',
kissing_closed_eyes: '😚',
kissing_smiling_eyes: '😙',
smiling_face_with_tear: '🥲',
yum: '😋',
stuck_out_tongue: '😛',
stuck_out_tongue_winking_eye: '😜',
zany_face: '🤪',
stuck_out_tongue_closed_eyes: '😝',
money_mouth_face: '🤑',
hugs: '🤗',
hand_over_mouth: '🤭',
shushing_face: '🤫',
thinking: '🤔',
zipper_mouth_face: '🤐',
raised_eyebrow: '🤨',
neutral_face: '😐',
expressionless: '😑',
no_mouth: '😶',
smirk: '😏',
unamused: '😒',
roll_eyes: '🙄',
grimacing: '😬',
lying_face: '🤥',
relieved: '😌',
pensive: '😔',
sleepy: '😪',
drooling_face: '🤤',
sleeping: '😴',
// Hearts
heart: '❤️',
orange_heart: '🧡',
yellow_heart: '💛',
green_heart: '💚',
blue_heart: '💙',
purple_heart: '💜',
brown_heart: '🤎',
black_heart: '🖤',
white_heart: '🤍',
// Gestures
'+1': '👍',
thumbsup: '👍',
'-1': '👎',
thumbsdown: '👎',
fist: '✊',
facepunch: '👊',
punch: '👊',
wave: '👋',
clap: '👏',
raised_hands: '🙌',
pray: '🙏',
handshake: '🤝',
// Nature
sun: '☀️',
moon: '🌙',
star: '⭐',
fire: '🔥',
zap: '⚡',
sparkles: '✨',
tada: '🎉',
rocket: '🚀',
trophy: '🏆',
// Symbols
check: '✔️',
x: '❌',
warning: '⚠️',
bulb: '💡',
question: '❓',
exclamation: '❗',
heavy_check_mark: '✔️',
// Common
eyes: '👀',
eye: '👁️',
brain: '🧠',
muscle: '💪',
ok_hand: '👌',
point_right: '👉',
point_left: '👈',
point_up: '☝️',
point_down: '👇',
};
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/g;
/**
* Widget to display emoji character.
* Common emoji mappings.
*/
const EMOJI_MAP: Map<string, string> = new Map([
// Smileys & Emotion
['smile', '😄'],
['smiley', '😃'],
['grin', '😁'],
['laughing', '😆'],
['satisfied', '😆'],
['sweat_smile', '😅'],
['rofl', '🤣'],
['joy', '😂'],
['slightly_smiling_face', '🙂'],
['upside_down_face', '🙃'],
['wink', '😉'],
['blush', '😊'],
['innocent', '😇'],
['smiling_face_with_three_hearts', '🥰'],
['heart_eyes', '😍'],
['star_struck', '🤩'],
['kissing_heart', '😘'],
['kissing', '😗'],
['relaxed', '☺️'],
['kissing_closed_eyes', '😚'],
['kissing_smiling_eyes', '😙'],
['smiling_face_with_tear', '🥲'],
['yum', '😋'],
['stuck_out_tongue', '😛'],
['stuck_out_tongue_winking_eye', '😜'],
['zany_face', '🤪'],
['stuck_out_tongue_closed_eyes', '😝'],
['money_mouth_face', '🤑'],
['hugs', '🤗'],
['hand_over_mouth', '🤭'],
['shushing_face', '🤫'],
['thinking', '🤔'],
['zipper_mouth_face', '🤐'],
['raised_eyebrow', '🤨'],
['neutral_face', '😐'],
['expressionless', '😑'],
['no_mouth', '😶'],
['smirk', '😏'],
['unamused', '😒'],
['roll_eyes', '🙄'],
['grimacing', '😬'],
['lying_face', '🤥'],
['relieved', '😌'],
['pensive', '😔'],
['sleepy', '😪'],
['drooling_face', '🤤'],
['sleeping', '😴'],
// Hearts
['heart', '❤️'],
['orange_heart', '🧡'],
['yellow_heart', '💛'],
['green_heart', '💚'],
['blue_heart', '💙'],
['purple_heart', '💜'],
['brown_heart', '🤎'],
['black_heart', '🖤'],
['white_heart', '🤍'],
// Gestures
['+1', '👍'],
['thumbsup', '👍'],
['-1', '👎'],
['thumbsdown', '👎'],
['fist', '✊'],
['facepunch', '👊'],
['punch', '👊'],
['wave', '👋'],
['clap', '👏'],
['raised_hands', '🙌'],
['pray', '🙏'],
['handshake', '🤝'],
// Nature
['sun', '☀️'],
['moon', '🌙'],
['star', '⭐'],
['fire', '🔥'],
['zap', '⚡'],
['sparkles', '✨'],
['tada', '🎉'],
['rocket', '🚀'],
['trophy', '🏆'],
// Symbols
['check', '✔️'],
['x', '❌'],
['warning', '⚠️'],
['bulb', '💡'],
['question', '❓'],
['exclamation', '❗'],
['heavy_check_mark', '✔️'],
// Common
['eyes', '👀'],
['eye', '👁️'],
['brain', '🧠'],
['muscle', '💪'],
['ok_hand', '👌'],
['point_right', '👉'],
['point_left', '👈'],
['point_up', '☝️'],
['point_down', '👇'],
]);
/**
* Reverse lookup map for emoji to name.
*/
const EMOJI_REVERSE_MAP = new Map<string, string>();
EMOJI_MAP.forEach((emoji, name) => {
if (!EMOJI_REVERSE_MAP.has(emoji)) {
EMOJI_REVERSE_MAP.set(emoji, name);
}
});
/**
* Emoji widget with optimized rendering.
*/
class EmojiWidget extends WidgetType {
constructor(readonly emoji: string) {
constructor(
readonly emoji: string,
readonly name: string
) {
super();
}
eq(other: EmojiWidget) {
eq(other: EmojiWidget): boolean {
return other.emoji === this.emoji;
}
@@ -146,62 +164,108 @@ class EmojiWidget extends WidgetType {
const span = document.createElement('span');
span.className = 'cm-emoji';
span.textContent = this.emoji;
span.title = ':' + Object.keys(EMOJI_MAP).find(
key => EMOJI_MAP[key] === this.emoji
) + ':';
span.title = `:${this.name}:`;
return span;
}
}
/**
* Plugin to render emoji.
* 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];
const emoji = EMOJI_MAP.get(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 = this.buildDecorations(view);
this.decorations = buildEmojiDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
// 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;
}
}
private buildDecorations(view: EditorView): DecorationSet {
const widgets: Array<ReturnType<Decoration['range']>> = [];
const doc = view.state.doc;
// For selection changes, check if we moved significantly
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
for (const { from, to } of view.visibleRanges) {
// Use regex to find :emoji: patterns
const text = doc.sliceString(from, to);
const emojiRegex = /:([a-z0-9_+\-]+):/g;
let match;
while ((match = emojiRegex.exec(text)) !== null) {
const matchStart = from + match.index;
const matchEnd = matchStart + match[0].length;
// Skip if cursor is in this range
if (isCursorInRange(view.state, [matchStart, matchEnd])) {
continue;
}
const emojiName = match[1];
const emojiChar = EMOJI_MAP[emojiName];
if (emojiChar) {
// Replace the :emoji: with the actual emoji
const widget = Decoration.replace({
widget: new EmojiWidget(emojiChar)
});
widgets.push(widget.range(matchStart, matchEnd));
}
// Only rebuild if cursor moved to a different position
if (newHead !== this.lastSelectionHead) {
this.decorations = buildEmojiDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
return Decoration.set(widgets, true);
}
}
@@ -227,13 +291,20 @@ const baseTheme = EditorView.baseTheme({
* @param emoji - Emoji character
*/
export function addEmoji(name: string, emoji: string): void {
EMOJI_MAP[name] = emoji;
EMOJI_MAP.set(name, emoji);
EMOJI_REVERSE_MAP.set(emoji, name);
}
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Object.keys(EMOJI_MAP);
return Array.from(EMOJI_MAP.keys());
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return EMOJI_MAP.get(name);
}

View File

@@ -53,8 +53,8 @@ export const frontmatter: MarkdownExtension = {
}
}
if (end > 0) {
children.push(cx.elt('FrontmatterMark', end - 4, end));
cx.addElement(cx.elt('Frontmatter', 0, end, children));
children.push(cx.elt('FrontmatterMark', end - 4, end));
cx.addElement(cx.elt('Frontmatter', 0, end, children));
}
return true;
} else {

View File

@@ -1,134 +1,96 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, iterateTreeInVisibleRanges } from '../util';
import { headingSlugField } from '../state/heading-slug';
import { heading as classes } from '../classes';
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField, Range } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
/**
* Ixora headings plugin.
*
* This plugin allows to:
* - Size headings according to their heading level
* - Add default styling to headings
* Hidden mark decoration - uses visibility: hidden to hide content
*/
export const headings = () => [
headingDecorationsPlugin,
hideHeaderMarkPlugin,
baseTheme
];
const hiddenMarkDecoration = Decoration.mark({
class: 'cm-heading-mark-hidden'
});
class HideHeaderMarkPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.hideHeaderMark(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet)
this.decorations = this.hideHeaderMark(update.view);
}
/**
* Function to decide if to insert a decoration to hide the header mark
* @param view - Editor view
* @returns The `Decoration`s that hide the header marks
*/
private hideHeaderMark(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
const ranges = view.state.selection.ranges;
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
// Get the active line
const line = view.lineBlockAt(from);
// If any cursor overlaps with the heading line, skip
const cursorOverlaps = ranges.some(({ from, to }) =>
checkRangeOverlap([from, to], [line.from, line.to])
);
if (cursorOverlaps) return;
if (
type.name === 'HeaderMark' &&
// Setext heading's horizontal lines are not hidden.
/[#]/.test(view.state.sliceDoc(from, to))
) {
const dec = Decoration.replace({});
widgets.push(dec.range(from, to + 1));
/**
* 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));
}
}
});
return Decoration.set(widgets, true);
}
}
/**
* Plugin to hide the header mark.
*
* The header mark will not be hidden when:
* - The cursor is on the active line
* - The mark is on a line which is in the current selection
*/
const hideHeaderMarkPlugin = ViewPlugin.fromClass(HideHeaderMarkPlugin, {
decorations: (v) => v.decorations
});
class HeadingDecorationsPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.decorateHeadings(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
) {
this.decorations = this.decorateHeadings(update.view);
}
}
private decorateHeadings(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
iterateTreeInVisibleRanges(view, {
enter: ({ name, from }) => {
// To capture ATXHeading and SetextHeading
if (!name.includes('Heading')) return;
const slug = view.state
.field(headingSlugField)
.find((s) => s.pos === from)?.slug;
const match = /[1-6]$/.exec(name);
if (!match) return;
const level = parseInt(match[0]);
const dec = Decoration.line({
class: [
classes.heading,
classes.level(level),
slug ? classes.slug(slug) : ''
].join(' ')
// 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)
);
}
});
widgets.push(dec.range(view.state.doc.lineAt(from).from));
}
});
return Decoration.set(widgets, true);
}
}
});
return Decoration.set(decorations, true);
}
const headingDecorationsPlugin = ViewPlugin.fromClass(
HeadingDecorationsPlugin,
{ decorations: (v) => v.decorations }
);
/**
* 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)
});
/**
* Base theme for headings.
* 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 baseTheme = EditorView.baseTheme({
'.cm-heading': {
fontWeight: 'bold'
},
['.' + classes.level(1)]: { fontSize: '2.2rem' },
['.' + classes.level(2)]: { fontSize: '1.8rem' },
['.' + classes.level(3)]: { fontSize: '1.4rem' },
['.' + classes.level(4)]: { fontSize: '1.2rem' },
['.' + classes.level(5)]: { fontSize: '1rem' },
['.' + classes.level(6)]: { fontSize: '0.8rem' }
const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': {
fontSize: '0'
}
});
/**
* Headings plugin.
*/
export const headings = () => [headingField, headingTheme];

View File

@@ -5,81 +5,133 @@ import {
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import {
checkRangeOverlap,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges
} from '../util';
import { RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, isCursorInRange } from '../util';
/**
* These types contain markers as child elements that can be hidden.
* Node types that contain markers as child elements.
*/
export const typesWithMarks = [
const TYPES_WITH_MARKS = new Set([
'Emphasis',
'StrongEmphasis',
'InlineCode',
'Strikethrough'
];
/**
* The elements which are used as marks.
*/
export const markTypes = ['EmphasisMark', 'CodeMark', 'StrikethroughMark'];
]);
/**
* Plugin to hide marks when the they are not in the editor selection.
* 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 = this.compute(view);
this.decorations = buildHideMarkDecorations(view);
this.lastSelectionRanges = this.serializeSelection(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet)
this.decorations = this.compute(update.view);
}
compute(view: EditorView): DecorationSet {
const widgets: Array<ReturnType<Decoration['range']>> = [];
let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (typesWithMarks.includes(type.name)) {
// There can be a possibility that the current node is a
// child eg. a bold node in a emphasis node, so check
// for that or else save the node range
if (
parentRange &&
checkRangeOverlap([from, to], parentRange)
)
return;
else parentRange = [from, to];
if (isCursorInRange(view.state, [from, to])) return;
const innerTree = node.toTree();
innerTree.iterate({
enter({ type, from: markFrom, to: markTo }) {
// Check for mark types and push the replace
// decoration
if (!markTypes.includes(type.name)) return;
widgets.push(
invisibleDecoration.range(
from + markFrom,
from + markTo
)
);
}
});
}
// 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;
}
});
return Decoration.set(widgets, true);
}
}
/**
* Serialize selection ranges for comparison.
*/
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map(r => `${r.from}:${r.to}`)
.join(',');
}
}
/**
* Ixora hide marks plugin.
* Hide marks plugin.
*
* This plugin allows to:
* - Hide marks when they are not in the editor selection.
* 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, {

View File

@@ -1,4 +1,4 @@
import { Extension, StateField, EditorState } from '@codemirror/state';
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
@@ -14,6 +14,7 @@ import { syntaxTree } from '@codemirror/language';
* 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,
@@ -21,16 +22,18 @@ export const horizontalRule = (): Extension => [
];
/**
* Widget to display a horizontal rule.
* Widget to display a horizontal rule (inline version).
*/
class HorizontalRuleWidget extends WidgetType {
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-horizontal-rule-container';
const span = document.createElement('span');
span.className = 'cm-horizontal-rule-widget';
const hr = document.createElement('hr');
hr.className = 'cm-horizontal-rule';
container.appendChild(hr);
return container;
span.appendChild(hr);
return span;
}
eq(_other: HorizontalRuleWidget) {
@@ -44,9 +47,10 @@ class HorizontalRuleWidget extends WidgetType {
/**
* Build horizontal rule decorations.
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
*/
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
const widgets: Array<ReturnType<Decoration['range']>> = [];
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
@@ -56,19 +60,20 @@ function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
if (isCursorInRange(state, [from, to])) return;
// Replace the entire horizontal rule with a styled widget
const widget = Decoration.replace({
widget: new HorizontalRuleWidget(),
block: true
});
widgets.push(widget.range(from, to));
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
decorations.push(
Decoration.replace({
widget: new HorizontalRuleWidget()
}).range(from, to)
);
}
});
return Decoration.set(widgets, true);
return Decoration.set(decorations, true);
}
/**
* StateField for horizontal rule decorations (must use StateField for block decorations).
* StateField for horizontal rule decorations.
*/
const horizontalRuleField = StateField.define<DecorationSet>({
create(state) {
@@ -87,21 +92,19 @@ const horizontalRuleField = StateField.define<DecorationSet>({
/**
* Base theme for horizontal rules.
* Uses inline-block display to render properly without block: true.
*/
const baseTheme = EditorView.baseTheme({
'.cm-horizontal-rule-container': {
display: 'flex',
alignItems: 'center',
padding: '0.5rem 0',
margin: '0.5rem 0',
userSelect: 'none'
'.cm-horizontal-rule-widget': {
display: 'inline-block',
width: '100%',
verticalAlign: 'middle'
},
'.cm-horizontal-rule': {
width: '100%',
height: '1px',
height: '0',
border: 'none',
borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))',
margin: '0'
margin: '0.5em 0'
}
});

View File

@@ -1,5 +1,5 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField } from '@codemirror/state';
import { EditorState, StateField, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
@@ -36,11 +36,11 @@ function extractHTMLBlocks(state: EditorState) {
return blocks;
}
function blockToDecoration(blocks: EmbedBlockData[]) {
function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
return blocks.map((block) =>
Decoration.widget({
widget: new HTMLBlockWidget(block),
block: true,
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
side: 1
}).range(block.to)
);
@@ -48,12 +48,13 @@ function blockToDecoration(blocks: EmbedBlockData[]) {
export const htmlBlock = StateField.define<DecorationSet>({
create(state) {
return Decoration.set(blockToDecoration(extractHTMLBlocks(state)));
return Decoration.set(blockToDecoration(extractHTMLBlocks(state)), true);
},
update(value, tx) {
if (tx.docChanged || tx.selection) {
return Decoration.set(
blockToDecoration(extractHTMLBlocks(tx.state))
blockToDecoration(extractHTMLBlocks(tx.state)),
true
);
}
return value.map(tx.changes);
@@ -64,19 +65,33 @@ export const htmlBlock = StateField.define<DecorationSet>({
});
class HTMLBlockWidget extends WidgetType {
constructor(public data: EmbedBlockData, public isInline?: true) {
constructor(public data: EmbedBlockData) {
super();
}
toDOM(): HTMLElement {
const dom = document.createElement('div');
dom.style.display = this.isInline ? 'inline' : 'block';
// Contain child margins
dom.style.overflow = 'auto';
const dom = document.createElement('span');
dom.className = 'cm-html-block-widget';
// This is sanitized!
dom.innerHTML = this.data.content;
return dom;
}
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',
width: '100%',
overflow: 'auto'
}
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlock, baseTheme];

View File

@@ -1,4 +1,5 @@
import { syntaxTree } from '@codemirror/language';
import { Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
@@ -8,147 +9,207 @@ import {
WidgetType
} from '@codemirror/view';
import { headingSlugField } from '../state/heading-slug';
import {
checkRangeOverlap,
invisibleDecoration,
isCursorInRange
} from '../util';
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
import { link as classes } from '../classes';
const autoLinkMarkRE = /^<|>$/g;
/**
* Pattern for auto-link markers (< and >).
*/
const AUTO_LINK_MARK_RE = /^<|>$/g;
/**
* Ixora Links plugin.
* Parent node types that should not have link widgets.
*/
const BLACKLISTED_PARENTS = new Set(['Image']);
/**
* Links plugin.
*
* This plugin allows to:
* - Add an interactive link icon to a URL which can navigate to the URL.
* Features:
* - Adds interactive link icon for navigation
* - Supports internal anchor links (#heading)
* - Hides link markup when cursor is outside
*/
export const links = () => [goToLinkPlugin, baseTheme];
/**
* Link widget for external/internal navigation.
*/
export class GoToLinkWidget extends WidgetType {
constructor(readonly link: string, readonly title?: string) {
constructor(
readonly link: string,
readonly title?: string
) {
super();
}
eq(other: GoToLinkWidget): boolean {
return other.link === this.link && other.title === this.title;
}
toDOM(view: EditorView): HTMLElement {
const anchor = document.createElement('a');
anchor.classList.add(classes.widget);
anchor.textContent = '🔗';
if (this.link.startsWith('#')) {
// Handle links within the markdown document.
const slugs = view.state.field(headingSlugField);
anchor.addEventListener('click', () => {
const pos = slugs.find(
(h) => h.slug === this.link.slice(1)
)?.pos;
// pos could be zero, so instead check if its undefined
// Handle internal anchor links
anchor.href = 'javascript:void(0)';
anchor.addEventListener('click', (e) => {
e.preventDefault();
const slugs = view.state.field(headingSlugField);
const targetSlug = this.link.slice(1);
const pos = slugs.find((h) => h.slug === targetSlug)?.pos;
if (typeof pos !== 'undefined') {
const tr = view.state.update({
view.dispatch({
selection: { anchor: pos },
scrollIntoView: true
});
view.dispatch(tr);
}
});
} else anchor.href = this.link;
anchor.target = '_blank';
anchor.classList.add(classes.widget);
anchor.textContent = '🔗';
if (this.title) anchor.title = this.title;
} else {
// External links
anchor.href = this.link;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
if (this.title) {
anchor.title = this.title;
}
return anchor;
}
ignoreEvent(): boolean {
return false;
}
}
function getLinkAnchor(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
/**
* Build link decorations.
* 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, to, node }) => {
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'URL') return;
const parent = node.parent;
// FIXME: make this configurable
const blackListedParents = ['Image'];
if (parent && !blackListedParents.includes(parent.name)) {
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
const ranges = view.state.selection.ranges;
let cursorOverlaps = ranges.some(({ from, to }) =>
checkRangeOverlap([from, to], [parent.from, parent.to])
);
if (!cursorOverlaps && marks.length > 0) {
widgets.push(
...marks.map(({ from, to }) =>
invisibleDecoration.range(from, to)
),
invisibleDecoration.range(from, to)
);
if (linkTitle)
widgets.push(
invisibleDecoration.range(
linkTitle.from,
linkTitle.to
)
);
}
let linkContent = view.state.sliceDoc(from, to);
if (autoLinkMarkRE.test(linkContent)) {
// Remove '<' and '>' from link and content
linkContent = linkContent.replace(autoLinkMarkRE, '');
cursorOverlaps = isCursorInRange(view.state, [
node.from,
node.to
]);
if (!cursorOverlaps) {
widgets.push(
invisibleDecoration.range(from, from + 1),
invisibleDecoration.range(to - 1, to)
);
}
const parent = node.parent;
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
// 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
let linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
// Handle auto-links with < > markers
if (AUTO_LINK_MARK_RE.test(linkContent)) {
linkContent = linkContent.replace(AUTO_LINK_MARK_RE, '');
if (!isCursorInRange(view.state, [node.from, node.to])) {
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
}
}
// Get link title content
const linkTitleContent = linkTitle
? view.state.sliceDoc(linkTitle.from, linkTitle.to)
: undefined;
const dec = Decoration.widget({
widget: new GoToLinkWidget(
linkContent,
linkTitleContent
),
// Add link widget
decorations.push(
Decoration.widget({
widget: new GoToLinkWidget(linkContent, linkTitleContent),
side: 1
});
widgets.push(dec.range(to, to));
}
}).range(nodeTo)
);
}
});
}
return Decoration.set(widgets, true);
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
}
export const goToLinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = getLinkAnchor(view);
/**
* 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;
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
)
this.decorations = getLinkAnchor(update.view);
// 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;
}
}
},
{ decorations: (v) => v.decorations }
);
}
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
});
/**
* Base theme for the links plugin.
* Base theme for links.
*/
const baseTheme = EditorView.baseTheme({
['.' + classes.widget]: {
[`.${classes.widget}`]: {
cursor: 'pointer',
textDecoration: 'underline'
textDecoration: 'none',
opacity: '0.7',
transition: 'opacity 0.2s'
},
[`.${classes.widget}:hover`]: {
opacity: '1'
}
});

View File

@@ -6,56 +6,28 @@ import {
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange, iterateTreeInVisibleRanges } from '../util';
import { ChangeSpec, Range } from '@codemirror/state';
import { NodeType, SyntaxNodeRef } from '@lezer/common';
import { Range, StateField, Transaction } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
import { list as classes } from '../classes';
const bulletListMarkerRE = /^[-+*]/;
/**
* Pattern for bullet list markers.
*/
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
/**
* Ixora Lists plugin.
* Lists plugin.
*
* This plugin allows to:
* - Customize list mark
* - Add an interactive checkbox for task lists
* Features:
* - Custom bullet mark rendering (- → •)
* - Interactive task list checkboxes
*/
export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme];
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
/**
* Plugin to add custom list bullet mark.
*/
class ListBulletPlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = this.decorateLists(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet)
this.decorations = this.decorateLists(update.view);
}
private decorateLists(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (isCursorInRange(view.state, [from, to])) return;
if (type.name === 'ListMark') {
const listMark = view.state.sliceDoc(from, to);
if (bulletListMarkerRE.test(listMark)) {
const dec = Decoration.replace({
widget: new ListBulletWidget(listMark)
});
widgets.push(dec.range(from, to));
}
}
}
});
return Decoration.set(widgets, true);
}
}
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations
});
// ============================================================================
// List Bullet Plugin
// ============================================================================
/**
* Widget to render list bullet mark.
@@ -64,114 +36,244 @@ class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) {
super();
}
eq(other: ListBulletWidget): boolean {
return other.bullet === this.bullet;
}
toDOM(): HTMLElement {
const listBullet = document.createElement('span');
listBullet.textContent = this.bullet;
listBullet.className = 'cm-list-bullet';
return listBullet;
const span = document.createElement('span');
span.className = classes.bullet;
span.textContent = '•';
return span;
}
}
/**
* Plugin to add checkboxes in task lists.
* Build list bullet decorations.
*/
class TaskListsPlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = this.addCheckboxes(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet)
this.decorations = this.addCheckboxes(update.view);
}
addCheckboxes(view: EditorView) {
const widgets: Range<Decoration>[] = [];
iterateTreeInVisibleRanges(view, {
enter: this.iterateTree(view, widgets)
});
return Decoration.set(widgets, true);
}
function buildListBulletDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
private iterateTree(view: EditorView, widgets: Range<Decoration>[]) {
return ({ type, from, to, node }: SyntaxNodeRef) => {
if (type.name !== 'Task') return;
let checked = false;
// Iterate inside the task node to find the checkbox
node.toTree().iterate({
enter: (ref) => iterateInner(ref.type, ref.from, ref.to)
});
if (checked)
widgets.push(
Decoration.mark({
tagName: 'span',
class: 'cm-task-checked'
}).range(from, to)
);
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;
function iterateInner(type: NodeType, nfrom: number, nto: number) {
if (type.name !== 'TaskMarker') return;
if (isCursorInRange(view.state, [from + nfrom, from + nto]))
return;
const checkbox = view.state.sliceDoc(from + nfrom, from + nto);
// Checkbox is checked if it has a 'x' in between the []
if ('xX'.includes(checkbox[1])) checked = true;
const dec = Decoration.replace({
widget: new CheckboxWidget(checked, from + nfrom + 1)
});
widgets.push(dec.range(from + nfrom, from + nto));
// 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 CheckboxWidget extends WidgetType {
constructor(public checked: boolean, readonly pos: number) {
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.classList.add(classes.taskCheckbox);
wrap.setAttribute('aria-hidden', 'true');
wrap.className = classes.taskCheckbox;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.addEventListener('click', ({ target }) => {
const change: ChangeSpec = {
from: this.pos,
to: this.pos + 1,
insert: this.checked ? ' ' : 'x'
};
view.dispatch({ changes: change });
this.checked = !this.checked;
(target as HTMLInputElement).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;
}
}
const taskListPlugin = ViewPlugin.fromClass(TaskListsPlugin, {
decorations: (v) => v.decorations
});
/**
* 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
// Add checked style to the entire task content
if (isChecked) {
decorations.push(
Decoration.mark({
class: classes.taskChecked
}).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);
}
/**
* Base theme for the lists plugin.
* Task list StateField - uses incremental updates to avoid flickering.
*/
const baseTheme = EditorView.baseTheme({
['.' + classes.bullet]: {
position: 'relative',
visibility: 'hidden'
const taskListField = StateField.define<DecorationSet>({
create(state) {
return buildTaskListDecorations(state);
},
['.' + classes.taskChecked]: {
textDecoration: 'line-through !important'
update(value, tr: Transaction) {
// Only rebuild when document or selection changes
if (tr.docChanged || tr.selection) {
return buildTaskListDecorations(tr.state);
}
return value;
},
['.' + classes.bullet + ':after']: {
visibility: 'visible',
position: 'absolute',
top: 0,
left: 0,
content: "'\\2022'" /* U+2022 BULLET */
provide(field) {
return EditorView.decorations.from(field);
}
});
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for lists.
*/
const baseTheme = EditorView.baseTheme({
[`.${classes.bullet}`]: {
// No extra width - just replace the character
color: 'var(--cm-list-bullet-color, inherit)'
},
[`.${classes.taskChecked}`]: {
textDecoration: 'line-through',
opacity: '0.6'
},
[`.${classes.taskCheckbox}`]: {
display: 'inline-block',
verticalAlign: 'baseline'
},
[`.${classes.taskCheckbox} input`]: {
cursor: 'pointer',
margin: '0',
marginRight: '0.35em',
width: '1em',
height: '1em',
position: 'relative',
top: '0.1em'
}
});

View File

@@ -1,161 +0,0 @@
import {
Annotation,
Line,
RangeSet,
RangeSetBuilder,
Extension
} from '@codemirror/state';
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
type DecorationSet
} from '@codemirror/view';
/**
* Soft indent plugin for better visual alignment of list items and blockquotes.
*
* This plugin:
* - Measures the width of list markers, blockquote markers, etc.
* - Applies padding to align the content properly
* - Updates dynamically as content changes
*/
export const softIndent = (): Extension => [softIndentPlugin];
interface IndentData {
line: Line;
indentWidth: number;
}
/**
* Pattern to match content that needs soft indentation:
* - Blockquote markers (> )
* - List markers (-, *, +, 1., etc.)
* - Task markers ([x] or [ ])
*/
const softIndentPattern = /^(> )*(\s*)?(([-*+]|\d+[.)])\s)?(\[.\]\s)?/;
const softIndentRefresh = Annotation.define<boolean>();
/**
* Plugin to apply soft indentation.
*/
class SoftIndentPlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.requestMeasure(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.requestMeasure(update.view);
}
if (update.transactions.some((tr) => tr.annotation(softIndentRefresh))) {
this.requestMeasure(update.view);
}
}
requestMeasure(view: EditorView) {
// Needs to run via requestMeasure since it measures and updates the DOM
view.requestMeasure({
read: (view) => this.measureIndents(view),
write: (indents, view) => {
this.applyIndents(indents, view);
}
});
}
/**
* Measure the indent width for each line that needs soft indentation.
*/
measureIndents(view: EditorView): IndentData[] {
const indents: IndentData[] = [];
// Loop through all visible lines
for (const { from, to } of view.visibleRanges) {
const start = view.state.doc.lineAt(from);
const end = view.state.doc.lineAt(to);
for (let i = start.number; i <= end.number; i++) {
// Get current line object
const line = view.state.doc.line(i);
// Match the line's text with the indent pattern
const text = view.state.sliceDoc(line.from, line.to);
const matches = softIndentPattern.exec(text);
if (!matches) continue;
const nonContent = matches[0];
if (!nonContent) continue;
// Get indent width by measuring DOM coordinates
const startCoords = view.coordsAtPos(line.from);
const endCoords = view.coordsAtPos(line.from + nonContent.length);
if (!startCoords || !endCoords) continue;
const indentWidth = endCoords.left - startCoords.left;
if (indentWidth <= 0) continue;
indents.push({
line,
indentWidth
});
}
}
return indents;
}
/**
* Build decorations from indent data.
*/
buildDecorations(indents: IndentData[]): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const { line, indentWidth } of indents) {
const deco = Decoration.line({
attributes: {
style: `padding-inline-start: ${Math.ceil(
indentWidth + 6
)}px; text-indent: -${Math.ceil(indentWidth)}px;`
}
});
builder.add(line.from, line.from, deco);
}
return builder.finish();
}
/**
* Apply new decorations and dispatch a transaction if needed.
*/
applyIndents(indents: IndentData[], view: EditorView) {
const newDecos = this.buildDecorations(indents);
let changed = false;
for (const { from, to } of view.visibleRanges) {
if (!RangeSet.eq([this.decorations], [newDecos], from, to)) {
changed = true;
break;
}
}
if (changed) {
queueMicrotask(() => {
view.dispatch({ annotations: [softIndentRefresh.of(true)] });
});
}
this.decorations = newDecos;
}
}
const softIndentPlugin = ViewPlugin.fromClass(SoftIndentPlugin, {
decorations: (v) => v.decorations
});

View File

@@ -48,11 +48,12 @@ export const imagePreview = StateField.define<DecorationSet>({
create(state) {
const images = extractImages(state);
const decorations = images.map((img) =>
// This does not need to be a block widget
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
Decoration.widget({
widget: new ImagePreviewWidget(img, WidgetState.INITIAL),
info: img,
src: img.src
src: img.src,
side: 1
}).range(img.to)
);
return Decoration.set(decorations, true);
@@ -86,9 +87,8 @@ export const imagePreview = StateField.define<DecorationSet>({
? WidgetState.LOADED
: WidgetState.INITIAL
),
// Create returns a inline widget, return inline image
// if image is not loaded for consistency.
block: hasImageLoaded ? true : false,
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
// Always use inline widget
src: img.src,
side: 1,
// This is important to keep track of loaded images
@@ -137,6 +137,9 @@ class ImagePreviewWidget extends WidgetType {
}
toDOM(view: EditorView): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cm-image-preview-wrapper';
const img = new Image();
img.classList.add(classes.widget);
img.src = this.info.src;
@@ -157,9 +160,11 @@ class ImagePreviewWidget extends WidgetType {
view.dispatch(tx);
});
if (this.state === WidgetState.LOADED) return img;
// Render placeholder
else return new Image();
if (this.state === WidgetState.LOADED) {
wrapper.appendChild(img);
}
// Return wrapper (empty for initial state, with img for loaded state)
return wrapper;
}
eq(widget: ImagePreviewWidget): boolean {

View File

@@ -1,49 +1,112 @@
import { foldedRanges, syntaxTree } from '@codemirror/language';
import type { SyntaxNodeRef } from '@lezer/common';
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
import { Decoration, EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import {
EditorState,
SelectionRange,
CharCategory,
findClusterBreak
} from '@codemirror/state';
// ============================================================================
// Type Definitions (ProseMark style)
// ============================================================================
/**
* Check if two ranges overlap
* A range-like object with from and to properties.
*/
export interface RangeLike {
from: number;
to: number;
}
/**
* Tuple representation of a range [from, to].
*/
export type RangeTuple = [number, number];
// ============================================================================
// Range Utilities
// ============================================================================
/**
* Check if two ranges overlap (touch or intersect).
* Based on the visual diagram on https://stackoverflow.com/a/25369187
* @param range1 - Range 1
* @param range2 - Range 2
*
* @param range1 - First range
* @param range2 - Second range
* @returns True if the ranges overlap
*/
export function checkRangeOverlap(
range1: [number, number],
range2: [number, number]
) {
range1: RangeTuple,
range2: RangeTuple
): boolean {
return range1[0] <= range2[1] && range2[0] <= range1[1];
}
/**
* Check if a range is inside another range
* Check if two range-like objects touch or overlap.
* ProseMark-style range comparison.
*
* @param a - First range
* @param b - Second range
* @returns True if ranges touch
*/
export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean {
return a.from <= b.to && b.from <= a.to;
}
/**
* Check if a selection touches a range.
*
* @param selection - Array of selection ranges
* @param range - Range to check against
* @returns True if any selection touches the range
*/
export function selectionTouchesRange(
selection: readonly SelectionRange[],
range: RangeLike
): boolean {
return selection.some((sel) => rangeTouchesRange(sel, range));
}
/**
* Check if a range is inside another range (subset).
*
* @param parent - Parent (bigger) range
* @param child - Child (smaller) range
* @returns True if child is inside parent
*/
export function checkRangeSubset(
parent: [number, number],
child: [number, number]
) {
parent: RangeTuple,
child: RangeTuple
): boolean {
return child[0] >= parent[0] && child[1] <= parent[1];
}
/**
* Check if any of the editor cursors is in the given range
* 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: [number, number]) {
export function isCursorInRange(
state: EditorState,
range: RangeTuple
): boolean {
return state.selection.ranges.some((selection) =>
checkRangeOverlap(range, [selection.from, selection.to])
);
}
// ============================================================================
// Tree Iteration Utilities
// ============================================================================
/**
* Iterate over the syntax tree in the visible ranges of the document
* Iterate over the syntax tree in the visible ranges of the document.
*
* @param view - Editor view
* @param iterateFns - Object with `enter` and `leave` iterate function
*/
@@ -53,30 +116,49 @@ export function iterateTreeInVisibleRanges(
enter(node: SyntaxNodeRef): boolean | void;
leave?(node: SyntaxNodeRef): void;
}
) {
): void {
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({ ...iterateFns, from, to });
}
}
/**
* Decoration to simply hide anything.
* Iterate through child nodes of a cursor.
* ProseMark-style tree traversal.
*
* @param cursor - Tree cursor to iterate
* @param enter - Callback function, return true to stop iteration
*/
export const invisibleDecoration = Decoration.replace({});
export function iterChildren(
cursor: TreeCursor,
enter: (cursor: TreeCursor) => boolean | undefined
): void {
if (!cursor.firstChild()) return;
do {
if (enter(cursor)) break;
} while (cursor.nextSibling());
cursor.parent();
}
// ============================================================================
// Line Utilities
// ============================================================================
/**
* Returns the lines of the editor that are in the given range and not folded.
* This function is of use when you need to get the lines of a particular
* block node and add line decorations to each line of it.
* This function is useful for adding line decorations to each line of a block node.
*
* @param view - Editor view
* @param from - Start of the range
* @param to - End of the range
* @returns A list of line blocks that are in the range
*/
export function editorLines(view: EditorView, from: number, to: number) {
export function editorLines(
view: EditorView,
from: number,
to: number
) {
let lines = view.viewportLineBlocks.filter((block) =>
// Keep lines that are in the range
checkRangeOverlap([block.from, block.to], [from, to])
);
@@ -96,28 +178,175 @@ export function editorLines(view: EditorView, from: number, to: number) {
}
/**
* Class containing methods to generate slugs from heading contents.
* Get line numbers for a range.
*
* @param state - Editor state
* @param from - Start position
* @param to - End position
* @returns Array of line numbers
*/
export function getLineNumbers(
state: EditorState,
from: number,
to: number
): number[] {
const startLine = state.doc.lineAt(from).number;
const endLine = state.doc.lineAt(to).number;
const lines: number[] = [];
for (let i = startLine; i <= endLine; i++) {
lines.push(i);
}
return lines;
}
// ============================================================================
// Word Utilities (ProseMark style)
// ============================================================================
/**
* Get the "WORD" at a position (vim-style WORD, including non-whitespace).
*
* @param state - Editor state
* @param pos - Position in document
* @returns Selection range of the WORD, or null if at whitespace
*/
export function stateWORDAt(
state: EditorState,
pos: number
): SelectionRange | null {
const { text, from, length } = state.doc.lineAt(pos);
const cat = state.charCategorizer(pos);
let start = pos - from;
let end = pos - from;
while (start > 0) {
const prev = findClusterBreak(text, start, false);
if (cat(text.slice(prev, start)) === CharCategory.Space) break;
start = prev;
}
while (end < length) {
const next = findClusterBreak(text, end);
if (cat(text.slice(end, next)) === CharCategory.Space) break;
end = next;
}
return start === end
? null
: { from: start + from, to: end + from } as SelectionRange;
}
// ============================================================================
// Decoration Utilities
// ============================================================================
/**
* Decoration to simply hide anything (replace with nothing).
*/
export const invisibleDecoration = Decoration.replace({});
/**
* Decoration to hide inline content (font-size: 0).
*/
export const hideInlineDecoration = Decoration.mark({
class: 'cm-hidden-token'
});
/**
* Decoration to make content transparent but preserve space.
*/
export const hideInlineKeepSpaceDecoration = Decoration.mark({
class: 'cm-transparent-token'
});
// ============================================================================
// Slug Generation
// ============================================================================
/**
* Class for generating unique slugs from heading contents.
*/
export class Slugger {
/** Occurrences for each slug. */
private occurences: { [key: string]: number } = {};
private occurrences: Map<string, number> = new Map();
/**
* Generate a slug from the given content.
*
* @param text - Content to generate the slug from
* @returns the slug
* @returns The generated slug
*/
public slug(text: string) {
public slug(text: string): string {
let slug = text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '');
if (slug in this.occurences) {
this.occurences[slug]++;
slug += '-' + this.occurences[slug];
} else {
this.occurences[slug] = 1;
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();
}
}
// ============================================================================
// Performance Utilities
// ============================================================================
/**
* Create a debounced version of a function.
*
* @param fn - Function to debounce
* @param delay - Delay in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
}) as T;
}
/**
* Create a throttled version of a function.
*
* @param fn - Function to throttle
* @param limit - Minimum time between calls in milliseconds
* @returns Throttled function
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limit: number
): T {
let lastCall = 0;
return ((...args: unknown[]) => {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn(...args);
}
}) as T;
}

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