🚧 Refactor markdown preview extension
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
];
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user