🚧 Refactor markdown preview extension
This commit is contained in:
@@ -5,9 +5,9 @@
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
import {cssLanguage} from "@codemirror/lang-css";
|
||||
@@ -22,9 +22,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -64,6 +64,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
import {languages} from "@codemirror/language-data";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
@@ -110,7 +111,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
matchClosingTags: true,
|
||||
autoCloseTags: true
|
||||
}),
|
||||
addKeymap: true,
|
||||
codeLanguages: languages,
|
||||
|
||||
}).language.parser, ["md"], {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
|
||||
@@ -64,11 +64,5 @@ export const blockquote = {
|
||||
emoji = {
|
||||
/** Emoji widget */
|
||||
widget: 'cm-emoji'
|
||||
},
|
||||
/** Classes for horizontal rule decorations. */
|
||||
horizontalRule = {
|
||||
/** Horizontal rule container */
|
||||
container: 'cm-horizontal-rule-container',
|
||||
/** Horizontal rule element */
|
||||
rule: 'cm-horizontal-rule'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,49 +8,17 @@ import { image } from './plugins/image';
|
||||
import { links } from './plugins/link';
|
||||
import { lists } from './plugins/list';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
import { imagePreview } from './state/image';
|
||||
|
||||
// New enhanced features
|
||||
import { codeblockEnhanced } from './plugins/code-block-enhanced';
|
||||
import { emoji } from './plugins/emoji';
|
||||
import { horizontalRule } from './plugins/horizontal-rule';
|
||||
import { revealOnArrow } from './plugins/reveal-on-arrow';
|
||||
import { pasteRichText } from './plugins/paste-rich-text';
|
||||
|
||||
// State fields
|
||||
export { headingSlugField } from './state/heading-slug';
|
||||
export { imagePreview } from './state/image';
|
||||
|
||||
// Core Extensions
|
||||
export { blockquote } from './plugins/blockquote';
|
||||
export { codeblock } from './plugins/code-block';
|
||||
export { frontmatter } from './plugins/frontmatter';
|
||||
export { headings } from './plugins/heading';
|
||||
export { hideMarks } from './plugins/hide-mark';
|
||||
export { image } from './plugins/image';
|
||||
export { htmlBlock, htmlBlockExtension } from './plugins/html';
|
||||
export { links } from './plugins/link';
|
||||
export { lists } from './plugins/list';
|
||||
|
||||
// Enhanced Extensions
|
||||
export { codeblockEnhanced } from './plugins/code-block-enhanced';
|
||||
export { emoji, addEmoji, getEmojiNames } from './plugins/emoji';
|
||||
export { horizontalRule } from './plugins/horizontal-rule';
|
||||
export { revealOnArrow } from './plugins/reveal-on-arrow';
|
||||
export { pasteRichText } from './plugins/paste-rich-text';
|
||||
|
||||
// Classes
|
||||
export * as classes from './classes';
|
||||
import { inlineCode } from './plugins/inline-code';
|
||||
|
||||
|
||||
/**
|
||||
* markdown extensions (includes all ProseMark-inspired features).
|
||||
* NOTE: All decorations avoid using block: true to prevent interfering
|
||||
* with the codeblock system's boundary calculations.
|
||||
* markdown extensions
|
||||
*/
|
||||
export const markdownExtensions: Extension = [
|
||||
headingSlugField,
|
||||
imagePreview,
|
||||
blockquote(),
|
||||
codeblock(),
|
||||
headings(),
|
||||
@@ -63,8 +31,7 @@ export const markdownExtensions: Extension = [
|
||||
codeblockEnhanced(),
|
||||
emoji(),
|
||||
horizontalRule(),
|
||||
revealOnArrow(),
|
||||
pasteRichText()
|
||||
inlineCode(),
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
|
||||
@@ -53,7 +53,7 @@ class CodeBlockInfoWidget extends WidgetType {
|
||||
// Copy button
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'cm-code-block-copy-btn';
|
||||
copyButton.title = '复制代码';
|
||||
copyButton.title = 'Copy';
|
||||
copyButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -162,46 +162,46 @@ const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, {
|
||||
|
||||
/**
|
||||
* Enhanced theme for code blocks.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
const enhancedTheme = EditorView.baseTheme({
|
||||
'.cm-code-block-info': {
|
||||
float: 'right',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.2rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.2s'
|
||||
gap: '0.4rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
opacity: '0.6',
|
||||
transition: 'opacity 0.15s ease'
|
||||
},
|
||||
'.cm-code-block-info:hover': {
|
||||
opacity: '1'
|
||||
},
|
||||
'.cm-code-block-lang': {
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: 'var(--voidraft-font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: '600',
|
||||
color: 'var(--cm-fg-muted, #888)'
|
||||
fontWeight: '500',
|
||||
letterSpacing: '0.02em',
|
||||
color: 'var(--cm-codeblock-lang-color)'
|
||||
},
|
||||
'.cm-code-block-copy-btn': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: '0.2rem',
|
||||
borderRadius: '0.25rem',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '0.2rem',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--cm-fg-muted, #888)',
|
||||
transition: 'background-color 0.2s, color 0.2s'
|
||||
color: 'var(--cm-codeblock-btn-color)',
|
||||
transition: 'background-color 0.15s ease, color 0.15s ease'
|
||||
},
|
||||
'.cm-code-block-copy-btn:hover': {
|
||||
backgroundColor: 'var(--cm-bg-hover, rgba(0, 0, 0, 0.1))',
|
||||
color: 'var(--cm-fg, inherit)'
|
||||
// backgroundColor: 'var(--cm-codeblock-btn-hover-bg)',
|
||||
color: 'var(--cm-codeblock-btn-hover-color)'
|
||||
},
|
||||
'.cm-code-block-copy-btn svg': {
|
||||
width: '16px',
|
||||
height: '16px'
|
||||
width: '14px',
|
||||
height: '14px'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -56,25 +56,26 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet {
|
||||
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;
|
||||
}
|
||||
// Determine line position class(es)
|
||||
const isFirst = lineNum === startLine.number;
|
||||
const isLast = lineNum === endLine.number;
|
||||
|
||||
// Build class list - a single line block needs both begin and end classes
|
||||
const positionClasses: string[] = [];
|
||||
if (isFirst) positionClasses.push(classes.widgetBegin);
|
||||
if (isLast) positionClasses.push(classes.widgetEnd);
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: `${classes.widget} ${positionClass}`.trim()
|
||||
class: `${classes.widget} ${positionClasses.join(' ')}`.trim()
|
||||
}).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Hide code markers when cursor is outside the block
|
||||
if (!cursorInBlock) {
|
||||
const codeBlock = node.toTree();
|
||||
codeBlock.iterate({
|
||||
const codeBlock = node.toTree();
|
||||
codeBlock.iterate({
|
||||
enter: ({ type: childType, from: childFrom, to: childTo }) => {
|
||||
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
|
||||
decorations.push(
|
||||
@@ -83,12 +84,12 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet {
|
||||
nodeFrom + childTo
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
||||
@@ -139,15 +140,18 @@ const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
|
||||
|
||||
/**
|
||||
* Base theme for code blocks.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
[`.${classes.widget}`]: {
|
||||
backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))'
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
},
|
||||
[`.${classes.widgetBegin}`]: {
|
||||
borderRadius: '5px 5px 0 0'
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)'
|
||||
},
|
||||
[`.${classes.widgetEnd}`]: {
|
||||
borderRadius: '0 0 5px 5px'
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ function isSelectionInRange(state: EditorState, from: number, to: number): boole
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -22,9 +22,7 @@ function extractHTMLBlocks(state: EditorState) {
|
||||
if (name !== 'HTMLBlock') return;
|
||||
if (isCursorInRange(state, [from, to])) return;
|
||||
const html = state.sliceDoc(from, to);
|
||||
const content = DOMPurify.sanitize(html, {
|
||||
FORBID_ATTR: ['style']
|
||||
});
|
||||
const content = DOMPurify.sanitize(html);
|
||||
|
||||
blocks.push({
|
||||
from,
|
||||
@@ -36,14 +34,26 @@ function extractHTMLBlocks(state: EditorState) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Decoration to hide the original HTML source code
|
||||
const hideDecoration = Decoration.replace({});
|
||||
|
||||
function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
|
||||
return blocks.map((block) =>
|
||||
Decoration.widget({
|
||||
widget: new HTMLBlockWidget(block),
|
||||
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
// Hide the original HTML source code
|
||||
decorations.push(hideDecoration.range(block.from, block.to));
|
||||
|
||||
// Add the preview widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new HTMLBlockWidget(block),
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
}
|
||||
|
||||
return decorations;
|
||||
}
|
||||
|
||||
export const htmlBlock = StateField.define<DecorationSet>({
|
||||
@@ -69,12 +79,38 @@ class HTMLBlockWidget extends WidgetType {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const dom = document.createElement('span');
|
||||
dom.className = 'cm-html-block-widget';
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'cm-html-block-widget';
|
||||
|
||||
// Content container
|
||||
const content = document.createElement('span');
|
||||
content.className = 'cm-html-block-content';
|
||||
// This is sanitized!
|
||||
dom.innerHTML = this.data.content;
|
||||
return dom;
|
||||
content.innerHTML = this.data.content;
|
||||
|
||||
// Edit button
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'cm-html-block-edit-btn';
|
||||
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>`;
|
||||
editBtn.title = 'Edit HTML';
|
||||
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
selection: { anchor: this.data.from }
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
|
||||
wrapper.appendChild(content);
|
||||
wrapper.appendChild(editBtn);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
eq(widget: HTMLBlockWidget): boolean {
|
||||
@@ -87,9 +123,36 @@ class HTMLBlockWidget extends WidgetType {
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-html-block-widget': {
|
||||
display: 'inline-block',
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-html-block-content': {
|
||||
display: 'block'
|
||||
},
|
||||
'.cm-html-block-edit-btn': {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
padding: '4px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(128, 128, 128, 0.2)',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s, background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: '10'
|
||||
},
|
||||
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
|
||||
opacity: '1'
|
||||
},
|
||||
'.cm-html-block-edit-btn:hover': {
|
||||
background: 'rgba(128, 128, 128, 0.4)'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
invisibleDecoration
|
||||
} from '../util';
|
||||
|
||||
function hideNodes(view: EditorView) {
|
||||
/**
|
||||
* Build decorations to hide image markdown syntax.
|
||||
* Only hides when cursor is outside the image range.
|
||||
*/
|
||||
function hideImageNodes(view: EditorView) {
|
||||
const widgets = new Array<Range<Decoration>>();
|
||||
iterateTreeInVisibleRanges(view, {
|
||||
enter(node) {
|
||||
@@ -29,32 +33,31 @@ function hideNodes(view: EditorView) {
|
||||
return Decoration.set(widgets, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to hide image markdown syntax when cursor is outside.
|
||||
*/
|
||||
const hideImageNodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = hideNodes(view);
|
||||
this.decorations = hideImageNodes(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet)
|
||||
this.decorations = hideNodes(update.view);
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = hideImageNodes(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
|
||||
/**
|
||||
* Ixora Image plugin.
|
||||
*
|
||||
* This plugin allows to
|
||||
* - Add a preview of an image in the document.
|
||||
*
|
||||
* @returns The image plugin.
|
||||
* Image plugin.
|
||||
*/
|
||||
export const image = (): Extension => [
|
||||
imagePreview,
|
||||
imagePreview(),
|
||||
hideImageNodePlugin,
|
||||
baseTheme
|
||||
];
|
||||
@@ -64,7 +67,6 @@ const baseTheme = EditorView.baseTheme({
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
maxWidth: '100%',
|
||||
paddingLeft: '4px',
|
||||
maxHeight: '100%',
|
||||
userSelect: 'none'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Inline code styling plugin.
|
||||
*
|
||||
* This plugin adds visual styling to inline code (`code`):
|
||||
* - Background color
|
||||
* - Border radius
|
||||
* - Padding effect via marks
|
||||
*/
|
||||
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Build inline code decorations.
|
||||
*/
|
||||
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||
if (type.name !== 'InlineCode') return;
|
||||
|
||||
// Get the actual code content (excluding backticks)
|
||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||
|
||||
// Find backtick positions
|
||||
let codeStart = nodeFrom;
|
||||
let codeEnd = nodeTo;
|
||||
|
||||
// Skip opening backticks
|
||||
let i = 0;
|
||||
while (i < text.length && text[i] === '`') {
|
||||
codeStart++;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Skip closing backticks
|
||||
let j = text.length - 1;
|
||||
while (j >= 0 && text[j] === '`') {
|
||||
codeEnd--;
|
||||
j--;
|
||||
}
|
||||
|
||||
// Only add decoration if there's actual content
|
||||
if (codeStart < codeEnd) {
|
||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Add mark decoration for the code content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : 'cm-inline-code'
|
||||
}).range(codeStart, codeEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code plugin class.
|
||||
*/
|
||||
class InlineCodePlugin {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildInlineCodeDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = buildInlineCodeDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for inline code.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-inline-code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.1rem 0.3rem',
|
||||
fontFamily: 'var(--voidraft-font-mono)',
|
||||
fontSize: '0.9em'
|
||||
},
|
||||
'.cm-inline-code-active': {
|
||||
// Slightly different style when cursor is inside
|
||||
backgroundColor: 'var(--cm-inline-code-bg)'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,12 +5,9 @@ import {
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { headingSlugField } from '../state/heading-slug';
|
||||
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
|
||||
import { link as classes } from '../classes';
|
||||
|
||||
/**
|
||||
* Pattern for auto-link markers (< and >).
|
||||
@@ -18,7 +15,7 @@ import { link as classes } from '../classes';
|
||||
const AUTO_LINK_MARK_RE = /^<|>$/g;
|
||||
|
||||
/**
|
||||
* Parent node types that should not have link widgets.
|
||||
* Parent node types that should not process.
|
||||
*/
|
||||
const BLACKLISTED_PARENTS = new Set(['Image']);
|
||||
|
||||
@@ -26,69 +23,14 @@ const BLACKLISTED_PARENTS = new Set(['Image']);
|
||||
* Links plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Adds interactive link icon for navigation
|
||||
* - Supports internal anchor links (#heading)
|
||||
* - Hides link markup when cursor is outside
|
||||
* - Link icons and click events are handled by hyperlink extension
|
||||
*/
|
||||
export const links = () => [goToLinkPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Link widget for external/internal navigation.
|
||||
*/
|
||||
export class GoToLinkWidget extends WidgetType {
|
||||
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 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') {
|
||||
view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
export const links = () => [goToLinkPlugin];
|
||||
|
||||
/**
|
||||
* Build link decorations.
|
||||
* Only hides markdown syntax marks, no icons added.
|
||||
* Uses array + Decoration.set() for automatic sorting.
|
||||
*/
|
||||
function buildLinkDecorations(view: EditorView): DecorationSet {
|
||||
@@ -126,30 +68,15 @@ function buildLinkDecorations(view: EditorView): DecorationSet {
|
||||
}
|
||||
|
||||
// Get link content
|
||||
let linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||
const 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;
|
||||
|
||||
// Add link widget
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new GoToLinkWidget(linkContent, linkTitleContent),
|
||||
side: 1
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -198,18 +125,3 @@ class LinkPlugin {
|
||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for links.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
[`.${classes.widget}`]: {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.2s'
|
||||
},
|
||||
[`.${classes.widget}:hover`]: {
|
||||
opacity: '1'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -270,7 +270,6 @@ const baseTheme = EditorView.baseTheme({
|
||||
[`.${classes.taskCheckbox} input`]: {
|
||||
cursor: 'pointer',
|
||||
margin: '0',
|
||||
marginRight: '0.35em',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
position: 'relative',
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { EditorView, keymap, type Command } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* Paste rich text as markdown.
|
||||
*
|
||||
* This plugin:
|
||||
* - Intercepts paste events containing HTML
|
||||
* - Converts HTML to Markdown format
|
||||
* - Supports common formatting: bold, italic, links, lists, etc.
|
||||
* - Provides Ctrl/Cmd+Shift+V for plain text paste
|
||||
*/
|
||||
export const pasteRichText = (): Extension => [
|
||||
pasteRichTextHandler,
|
||||
pastePlainTextKeymap
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert HTML to Markdown.
|
||||
* Simplified implementation for common HTML elements.
|
||||
*/
|
||||
function htmlToMarkdown(html: string): string {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
|
||||
return convertNode(temp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DOM node to Markdown recursively.
|
||||
*/
|
||||
function convertNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || '';
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const children = Array.from(element.childNodes)
|
||||
.map(convertNode)
|
||||
.join('');
|
||||
|
||||
switch (element.tagName.toLowerCase()) {
|
||||
case 'strong':
|
||||
case 'b':
|
||||
return `**${children}**`;
|
||||
|
||||
case 'em':
|
||||
case 'i':
|
||||
return `*${children}*`;
|
||||
|
||||
case 'code':
|
||||
return `\`${children}\``;
|
||||
|
||||
case 'pre':
|
||||
return `\n\`\`\`\n${children}\n\`\`\`\n`;
|
||||
|
||||
case 'a': {
|
||||
const href = element.getAttribute('href') || '';
|
||||
return `[${children}](${href})`;
|
||||
}
|
||||
|
||||
case 'img': {
|
||||
const src = element.getAttribute('src') || '';
|
||||
const alt = element.getAttribute('alt') || '';
|
||||
return ``;
|
||||
}
|
||||
|
||||
case 'h1':
|
||||
return `\n# ${children}\n`;
|
||||
case 'h2':
|
||||
return `\n## ${children}\n`;
|
||||
case 'h3':
|
||||
return `\n### ${children}\n`;
|
||||
case 'h4':
|
||||
return `\n#### ${children}\n`;
|
||||
case 'h5':
|
||||
return `\n##### ${children}\n`;
|
||||
case 'h6':
|
||||
return `\n###### ${children}\n`;
|
||||
|
||||
case 'p':
|
||||
return `\n${children}\n`;
|
||||
|
||||
case 'br':
|
||||
return '\n';
|
||||
|
||||
case 'hr':
|
||||
return '\n---\n';
|
||||
|
||||
case 'blockquote':
|
||||
return children
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
|
||||
case 'ul':
|
||||
case 'ol': {
|
||||
const items = Array.from(element.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === 'li')
|
||||
.map((li, index) => {
|
||||
const content = convertNode(li).trim();
|
||||
const marker =
|
||||
element.tagName.toLowerCase() === 'ul'
|
||||
? '-'
|
||||
: `${index + 1}.`;
|
||||
return `${marker} ${content}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `\n${items}\n`;
|
||||
}
|
||||
|
||||
case 'li':
|
||||
return children;
|
||||
|
||||
case 'table':
|
||||
case 'thead':
|
||||
case 'tbody':
|
||||
case 'tr':
|
||||
case 'th':
|
||||
case 'td':
|
||||
// Simple table handling - just extract text
|
||||
return children;
|
||||
|
||||
case 'div':
|
||||
case 'span':
|
||||
case 'article':
|
||||
case 'section':
|
||||
return children;
|
||||
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for paste events with HTML content.
|
||||
*/
|
||||
const pasteRichTextHandler = EditorView.domEventHandlers({
|
||||
paste(event, view) {
|
||||
const html = event.clipboardData?.getData('text/html');
|
||||
if (!html) {
|
||||
// No HTML content, let default paste handler work
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const markdown = htmlToMarkdown(html);
|
||||
|
||||
// Insert the markdown at cursor position
|
||||
const from = view.state.selection.main.from;
|
||||
const to = view.state.selection.main.to;
|
||||
const newPos = from + markdown.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: markdown },
|
||||
selection: { anchor: newPos },
|
||||
scrollIntoView: true
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Plain text paste command (Ctrl/Cmd+Shift+V).
|
||||
*/
|
||||
const pastePlainTextCommand: Command = (view: EditorView) => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
const from = view.state.selection.main.from;
|
||||
const to = view.state.selection.main.to;
|
||||
const newPos = from + text.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: newPos },
|
||||
scrollIntoView: true
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to paste plain text:', err);
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keymap for plain text paste.
|
||||
*/
|
||||
const pastePlainTextKeymap = keymap.of([
|
||||
{ key: 'Mod-Shift-v', run: pastePlainTextCommand }
|
||||
]);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Extension, EditorSelection } from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* Reveal block on arrow key navigation.
|
||||
*
|
||||
* This plugin:
|
||||
* - Detects when arrow keys are pressed
|
||||
* - Helps navigate through folded/hidden content
|
||||
* - Provides better UX when navigating markdown documents
|
||||
*
|
||||
* Note: This is a simplified implementation that works with the
|
||||
* standard CodeMirror navigation. For more advanced behavior,
|
||||
* consider using the cursor position to detect nearby decorations.
|
||||
*/
|
||||
export const revealOnArrow = (): Extension => [revealOnArrowKeymap];
|
||||
|
||||
/**
|
||||
* Check if we should adjust cursor position for better navigation.
|
||||
* This is a basic implementation that lets CodeMirror handle most navigation.
|
||||
*/
|
||||
function maybeReveal(
|
||||
view: EditorView,
|
||||
direction: 'up' | 'down'
|
||||
): boolean {
|
||||
const { state } = view;
|
||||
const cursorAt = state.selection.main.head;
|
||||
const doc = state.doc;
|
||||
|
||||
// Basic navigation enhancement
|
||||
// Let CodeMirror handle the navigation naturally
|
||||
// This hook is here for future enhancements if needed
|
||||
|
||||
if (direction === 'down') {
|
||||
// Moving down: check if we're at the end of a line
|
||||
const line = doc.lineAt(cursorAt);
|
||||
if (cursorAt === line.to && line.number < doc.lines) {
|
||||
// Let CodeMirror handle moving to next line
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Moving up: check if we're at the start of a line
|
||||
const line = doc.lineAt(cursorAt);
|
||||
if (cursorAt === line.from && line.number > 1) {
|
||||
// Let CodeMirror handle moving to previous line
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keymap for revealing blocks on arrow navigation.
|
||||
*/
|
||||
const revealOnArrowKeymap = keymap.of([
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
run: (view) => maybeReveal(view, 'up')
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
run: (view) => maybeReveal(view, 'down')
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
StateField,
|
||||
EditorState,
|
||||
StateEffect,
|
||||
TransactionSpec
|
||||
} from '@codemirror/state';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
EditorView
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { image as classes } from '../classes';
|
||||
|
||||
/**
|
||||
* Representation of the data held by the image URL state field.
|
||||
* Representation of image data extracted from the syntax tree.
|
||||
*/
|
||||
export interface ImageInfo {
|
||||
/** The source of the image. */
|
||||
/** The source URL of the image. */
|
||||
src: string;
|
||||
/** The starting position of the image element in the document. */
|
||||
from: number;
|
||||
@@ -25,113 +23,74 @@ export interface ImageInfo {
|
||||
to: number;
|
||||
/** The alt text of the image. */
|
||||
alt: string;
|
||||
/** If image has already loaded. */
|
||||
loaded?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of the image preview widget.
|
||||
* Used to indicate to render a placeholder or the actual image.
|
||||
*/
|
||||
export enum WidgetState {
|
||||
INITIAL,
|
||||
LOADED
|
||||
}
|
||||
|
||||
/**
|
||||
* The state effect to dispatch when a image loads, regardless of the result.
|
||||
*/
|
||||
export const imageLoadedEffect = StateEffect.define<ImageInfo>();
|
||||
|
||||
/** State field to store image preview decorations. */
|
||||
export const imagePreview = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
const images = extractImages(state);
|
||||
const decorations = images.map((img) =>
|
||||
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
|
||||
Decoration.widget({
|
||||
widget: new ImagePreviewWidget(img, WidgetState.INITIAL),
|
||||
info: img,
|
||||
src: img.src,
|
||||
side: 1
|
||||
}).range(img.to)
|
||||
);
|
||||
return Decoration.set(decorations, true);
|
||||
},
|
||||
|
||||
update(value, tx) {
|
||||
const loadedImages = tx.effects.filter((effect) =>
|
||||
effect.is(imageLoadedEffect)
|
||||
) as StateEffect<ImageInfo>[];
|
||||
|
||||
if (tx.docChanged || loadedImages.length > 0) {
|
||||
const images = extractImages(tx.state);
|
||||
const previous = value.iter();
|
||||
const previousSpecs = new Array<ImageInfo>();
|
||||
while (previous.value !== null) {
|
||||
previousSpecs.push(previous.value.spec.info);
|
||||
previous.next();
|
||||
}
|
||||
const decorations = images.map((img) => {
|
||||
const hasImageLoaded = Boolean(
|
||||
loadedImages.find(
|
||||
(effect) => effect.value.src === img.src
|
||||
) ||
|
||||
previousSpecs.find((spec) => spec.src === img.src)
|
||||
?.loaded
|
||||
);
|
||||
return Decoration.widget({
|
||||
widget: new ImagePreviewWidget(
|
||||
img,
|
||||
hasImageLoaded
|
||||
? WidgetState.LOADED
|
||||
: WidgetState.INITIAL
|
||||
),
|
||||
// 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
|
||||
info: { ...img, loaded: hasImageLoaded }
|
||||
}).range(img.to);
|
||||
});
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
return value.map(tx.changes);
|
||||
},
|
||||
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Capture everything in square brackets of a markdown image, after
|
||||
* the exclamation mark.
|
||||
*/
|
||||
const imageTextRE = /(?:!\[)(.*?)(?:\])/;
|
||||
const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/;
|
||||
|
||||
function extractImages(state: EditorState): ImageInfo[] {
|
||||
const imageUrls: ImageInfo[] = [];
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ name, node, from, to }) => {
|
||||
if (name !== 'Image') return;
|
||||
const altMatch = state.sliceDoc(from, to).match(imageTextRE);
|
||||
const alt: string = altMatch?.pop() ?? '';
|
||||
const urlNode = node.getChild('URL');
|
||||
if (urlNode) {
|
||||
const url: string = state.sliceDoc(urlNode.from, urlNode.to);
|
||||
imageUrls.push({ src: url, from, to, alt });
|
||||
/**
|
||||
* Extract images from the syntax tree.
|
||||
*/
|
||||
function extractImages(view: EditorView): ImageInfo[] {
|
||||
const images: ImageInfo[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ name, node, from: nodeFrom, to: nodeTo }) => {
|
||||
if (name !== 'Image') return;
|
||||
const altMatch = view.state.sliceDoc(nodeFrom, nodeTo).match(IMAGE_TEXT_RE);
|
||||
const alt: string = altMatch?.pop() ?? '';
|
||||
const urlNode = node.getChild('URL');
|
||||
if (urlNode) {
|
||||
const url: string = view.state.sliceDoc(urlNode.from, urlNode.to);
|
||||
images.push({ src: url, from: nodeFrom, to: nodeTo, alt });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return imageUrls;
|
||||
});
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build image preview decorations.
|
||||
* Only shows preview when cursor is outside the image syntax.
|
||||
*/
|
||||
function buildImageDecorations(view: EditorView, loadedImages: Set<string>): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const images = extractImages(view);
|
||||
|
||||
for (const img of images) {
|
||||
const cursorInImage = isCursorInRange(view.state, [img.from, img.to]);
|
||||
|
||||
// Only show preview when cursor is outside
|
||||
if (!cursorInImage) {
|
||||
const isLoaded = loadedImages.has(img.src);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new ImagePreviewWidget(img, isLoaded, loadedImages),
|
||||
side: 1
|
||||
}).range(img.to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image preview widget that displays the actual image.
|
||||
*/
|
||||
class ImagePreviewWidget extends WidgetType {
|
||||
constructor(
|
||||
public readonly info: ImageInfo,
|
||||
public readonly state: WidgetState
|
||||
private readonly info: ImageInfo,
|
||||
private readonly isLoaded: boolean,
|
||||
private readonly loadedImages: Set<string>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -145,32 +104,106 @@ class ImagePreviewWidget extends WidgetType {
|
||||
img.src = this.info.src;
|
||||
img.alt = this.info.alt;
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
const tx: TransactionSpec = {};
|
||||
if (this.state === WidgetState.INITIAL) {
|
||||
tx.effects = [
|
||||
// Indicate image has loaded by setting the loaded value
|
||||
imageLoadedEffect.of({ ...this.info, loaded: true })
|
||||
];
|
||||
}
|
||||
// After this is dispatched, this widget will be updated,
|
||||
// and since the image is already loaded, this will not change
|
||||
// its height dynamically, hence prevent all sorts of weird
|
||||
// mess related to other parts of the editor.
|
||||
view.dispatch(tx);
|
||||
});
|
||||
if (!this.isLoaded) {
|
||||
img.addEventListener('load', () => {
|
||||
this.loadedImages.add(this.info.src);
|
||||
view.dispatch({});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state === WidgetState.LOADED) {
|
||||
if (this.isLoaded) {
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.className = 'cm-image-loading';
|
||||
placeholder.textContent = '🖼️';
|
||||
wrapper.appendChild(placeholder);
|
||||
img.style.display = 'none';
|
||||
wrapper.appendChild(img);
|
||||
}
|
||||
// Return wrapper (empty for initial state, with img for loaded state)
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
eq(widget: ImagePreviewWidget): boolean {
|
||||
return (
|
||||
JSON.stringify(widget.info) === JSON.stringify(this.info) &&
|
||||
widget.state === this.state
|
||||
widget.info.src === this.info.src &&
|
||||
widget.info.from === this.info.from &&
|
||||
widget.info.to === this.info.to &&
|
||||
widget.isLoaded === this.isLoaded
|
||||
);
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image preview plugin class.
|
||||
*/
|
||||
class ImagePreviewPlugin {
|
||||
decorations: DecorationSet;
|
||||
private loadedImages: Set<string> = new Set();
|
||||
private lastSelectionRanges: string = '';
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildImageDecorations(view, this.loadedImages);
|
||||
this.lastSelectionRanges = this.serializeSelection(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newRanges = this.serializeSelection(update.view);
|
||||
if (newRanges !== this.lastSelectionRanges) {
|
||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
||||
this.lastSelectionRanges = newRanges;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!update.docChanged && !update.selectionSet && !update.viewportChanged) {
|
||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeSelection(view: EditorView): string {
|
||||
return view.state.selection.ranges
|
||||
.map((r) => `${r.from}:${r.to}`)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image preview extension.
|
||||
* Only handles displaying image preview widget.
|
||||
*/
|
||||
export const imagePreview = (): Extension => [
|
||||
ViewPlugin.fromClass(ImagePreviewPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
}),
|
||||
baseTheme
|
||||
];
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-image-preview-wrapper': {
|
||||
display: 'block',
|
||||
margin: '0.5rem 0'
|
||||
},
|
||||
[`.${classes.widget}`]: {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '0.25rem'
|
||||
},
|
||||
'.cm-image-loading': {
|
||||
display: 'inline-block',
|
||||
color: 'var(--cm-foreground)',
|
||||
opacity: '0.6'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,19 +247,7 @@ export function stateWORDAt(
|
||||
*/
|
||||
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
|
||||
@@ -301,52 +289,4 @@ export class Slugger {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user