🚧 Refactor markdown preview extension
This commit is contained in:
@@ -53,11 +53,7 @@
|
|||||||
/* Markdown 代码块样式 - 暗色主题 */
|
/* Markdown 代码块样式 - 暗色主题 */
|
||||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||||
--cm-codeblock-radius: 0.4rem;
|
--cm-codeblock-radius: 0.4rem;
|
||||||
--cm-codeblock-lang-color: oklch(65% 0.03 257);
|
|
||||||
--cm-codeblock-btn-bg: oklch(28% 0.02 253);
|
|
||||||
--cm-codeblock-btn-hover-bg: oklch(38% 0.035 257);
|
|
||||||
--cm-codeblock-btn-color: oklch(65% 0.03 257);
|
|
||||||
--cm-codeblock-btn-hover-color: oklch(85% 0.015 255);
|
|
||||||
|
|
||||||
/* Markdown 内联代码样式 */
|
/* Markdown 内联代码样式 */
|
||||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||||
@@ -68,12 +64,6 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||||
|
|
||||||
/* Markdown 脚注样式 */
|
|
||||||
--cm-footnote-ref-color: #818cf8;
|
|
||||||
--cm-footnote-ref-hover-bg: rgba(129, 140, 248, 0.15);
|
|
||||||
--cm-footnote-undefined-color: #f87171;
|
|
||||||
--cm-footnote-def-color: #818cf8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 亮色主题 */
|
/* 亮色主题 */
|
||||||
@@ -125,11 +115,6 @@
|
|||||||
/* Markdown 代码块样式 - 亮色主题 */
|
/* Markdown 代码块样式 - 亮色主题 */
|
||||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||||
--cm-codeblock-radius: 0.4rem;
|
--cm-codeblock-radius: 0.4rem;
|
||||||
--cm-codeblock-lang-color: oklch(37.2% 0.044 257.287);
|
|
||||||
--cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894);
|
|
||||||
--cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788);
|
|
||||||
--cm-codeblock-btn-color: oklch(37.2% 0.044 257.287);
|
|
||||||
--cm-codeblock-btn-hover-color: oklch(20% 0.044 257);
|
|
||||||
|
|
||||||
/* Markdown 内联代码样式 */
|
/* Markdown 内联代码样式 */
|
||||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||||
@@ -140,12 +125,6 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
|
|
||||||
/* Markdown 脚注样式 */
|
|
||||||
--cm-footnote-ref-color: #6366f1;
|
|
||||||
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
|
|
||||||
--cm-footnote-undefined-color: #ef4444;
|
|
||||||
--cm-footnote-def-color: #6366f1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 跟随系统的浅色偏好 */
|
/* 跟随系统的浅色偏好 */
|
||||||
@@ -198,11 +177,6 @@
|
|||||||
/* Markdown 代码块样式 - 亮色主题 */
|
/* Markdown 代码块样式 - 亮色主题 */
|
||||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||||
--cm-codeblock-radius: 0.4rem;
|
--cm-codeblock-radius: 0.4rem;
|
||||||
--cm-codeblock-lang-color: oklch(37.2% 0.044 257.287);
|
|
||||||
--cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894);
|
|
||||||
--cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788);
|
|
||||||
--cm-codeblock-btn-color: oklch(37.2% 0.044 257.287);
|
|
||||||
--cm-codeblock-btn-hover-color: oklch(20% 0.044 257);
|
|
||||||
|
|
||||||
/* Markdown 内联代码样式 */
|
/* Markdown 内联代码样式 */
|
||||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||||
@@ -213,11 +187,5 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
|
|
||||||
/* Markdown 脚注样式 */
|
|
||||||
--cm-footnote-ref-color: #6366f1;
|
|
||||||
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
|
|
||||||
--cm-footnote-undefined-color: #ef4444;
|
|
||||||
--cm-footnote-def-color: #6366f1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useConfigStore } from './configStore';
|
|||||||
import { useEditorStore } from './editorStore';
|
import { useEditorStore } from './editorStore';
|
||||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||||
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
||||||
import { refreshMermaidTheme } from '@/views/editor/extensions/markdown/plugins/mermaid';
|
|
||||||
|
|
||||||
type ThemeOption = { name: string; type: ThemeType };
|
type ThemeOption = { name: string; type: ThemeType };
|
||||||
|
|
||||||
@@ -139,10 +138,6 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
|
|
||||||
const refreshEditorTheme = () => {
|
const refreshEditorTheme = () => {
|
||||||
applyThemeToDOM(currentTheme.value);
|
applyThemeToDOM(currentTheme.value);
|
||||||
|
|
||||||
// Refresh mermaid diagrams with new theme
|
|
||||||
refreshMermaidTheme();
|
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
editorStore?.applyThemeSettings();
|
editorStore?.applyThemeSettings();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascrip
|
|||||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||||
import {StandardSQL} from "@codemirror/lang-sql";
|
import {StandardSQL} from "@codemirror/lang-sql";
|
||||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||||
import {Subscript, Superscript} from "@lezer/markdown";
|
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||||
import {javaLanguage} from "@codemirror/lang-java";
|
import {javaLanguage} from "@codemirror/lang-java";
|
||||||
import {phpLanguage} from "@codemirror/lang-php";
|
import {phpLanguage} from "@codemirror/lang-php";
|
||||||
@@ -115,7 +115,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdown({
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
base: markdownLanguage,
|
base: markdownLanguage,
|
||||||
extensions: [Subscript, Superscript, Highlight],
|
extensions: [Subscript, Superscript, Highlight, Table],
|
||||||
completeHTMLTags: true,
|
completeHTMLTags: true,
|
||||||
pasteURLAsLink: true,
|
pasteURLAsLink: true,
|
||||||
htmlTagLanguage: html({
|
htmlTagLanguage: html({
|
||||||
|
|||||||
@@ -60,18 +60,35 @@ export const blockquote = {
|
|||||||
/** Copy button */
|
/** Copy button */
|
||||||
copyBtn: 'cm-code-block-copy-btn'
|
copyBtn: 'cm-code-block-copy-btn'
|
||||||
},
|
},
|
||||||
/** Classes for emoji decorations. */
|
/** Classes for table decorations. */
|
||||||
emoji = {
|
table = {
|
||||||
/** Emoji widget */
|
/** Table container wrapper */
|
||||||
widget: 'cm-emoji'
|
wrapper: 'cm-table-wrapper',
|
||||||
},
|
/** The rendered table element */
|
||||||
/** Classes for mermaid diagram decorations. */
|
table: 'cm-table',
|
||||||
mermaid = {
|
/** Table header row */
|
||||||
/** Mermaid preview container */
|
header: 'cm-table-header',
|
||||||
preview: 'cm-mermaid-preview',
|
/** Table header cell */
|
||||||
/** Loading state */
|
headerCell: 'cm-table-header-cell',
|
||||||
loading: 'cm-mermaid-loading',
|
/** Table body */
|
||||||
/** Error state */
|
body: 'cm-table-body',
|
||||||
error: 'cm-mermaid-error'
|
/** Table data row */
|
||||||
|
row: 'cm-table-row',
|
||||||
|
/** Table data cell */
|
||||||
|
cell: 'cm-table-cell',
|
||||||
|
/** Cell alignment classes */
|
||||||
|
alignLeft: 'cm-table-align-left',
|
||||||
|
alignCenter: 'cm-table-align-center',
|
||||||
|
alignRight: 'cm-table-align-right',
|
||||||
|
/** Cell content wrapper (for editing) */
|
||||||
|
cellContent: 'cm-table-cell-content',
|
||||||
|
/** Resize handle */
|
||||||
|
resizeHandle: 'cm-table-resize-handle',
|
||||||
|
/** Active editing cell */
|
||||||
|
cellActive: 'cm-table-cell-active',
|
||||||
|
/** Row hover state */
|
||||||
|
rowHover: 'cm-table-row-hover',
|
||||||
|
/** Selected cell */
|
||||||
|
cellSelected: 'cm-table-cell-selected'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import { image } from './plugins/image';
|
|||||||
import { links } from './plugins/link';
|
import { links } from './plugins/link';
|
||||||
import { lists } from './plugins/list';
|
import { lists } from './plugins/list';
|
||||||
import { headingSlugField } from './state/heading-slug';
|
import { headingSlugField } from './state/heading-slug';
|
||||||
import { codeblockEnhanced } from './plugins/code-block-enhanced';
|
|
||||||
import { emoji } from './plugins/emoji';
|
import { emoji } from './plugins/emoji';
|
||||||
import { horizontalRule } from './plugins/horizontal-rule';
|
import { horizontalRule } from './plugins/horizontal-rule';
|
||||||
import { inlineCode } from './plugins/inline-code';
|
import { inlineCode } from './plugins/inline-code';
|
||||||
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
||||||
import { highlight } from './plugins/highlight';
|
import { highlight } from './plugins/highlight';
|
||||||
import { mermaidPreview } from './plugins/mermaid';
|
import { table } from './plugins/table';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,14 +29,12 @@ export const markdownExtensions: Extension = [
|
|||||||
links(),
|
links(),
|
||||||
image(),
|
image(),
|
||||||
htmlBlockExtension,
|
htmlBlockExtension,
|
||||||
// Enhanced features
|
|
||||||
codeblockEnhanced(),
|
|
||||||
emoji(),
|
emoji(),
|
||||||
horizontalRule(),
|
horizontalRule(),
|
||||||
inlineCode(),
|
inlineCode(),
|
||||||
subscriptSuperscript(),
|
subscriptSuperscript(),
|
||||||
highlight(),
|
highlight(),
|
||||||
mermaidPreview(),
|
table(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export default markdownExtensions;
|
export default markdownExtensions;
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
|||||||
|
|
||||||
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
||||||
|
|
||||||
|
// Only add decorations when cursor is outside the blockquote
|
||||||
|
// This allows selection highlighting to be visible when editing
|
||||||
|
if (!cursorInBlockquote) {
|
||||||
// Add line decoration for each line in the blockquote
|
// Add line decoration for each line in the blockquote
|
||||||
const startLine = view.state.doc.lineAt(node.from).number;
|
const startLine = view.state.doc.lineAt(node.from).number;
|
||||||
const endLine = view.state.doc.lineAt(node.to).number;
|
const endLine = view.state.doc.lineAt(node.to).number;
|
||||||
@@ -50,7 +53,6 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hide quote marks when cursor is outside
|
// Hide quote marks when cursor is outside
|
||||||
if (!cursorInBlockquote) {
|
|
||||||
const cursor = node.node.cursor();
|
const cursor = node.node.cursor();
|
||||||
cursor.iterate((child) => {
|
cursor.iterate((child) => {
|
||||||
if (child.type.name === 'QuoteMark') {
|
if (child.type.name === 'QuoteMark') {
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import { Extension } from '@codemirror/state';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { isCursorInRange } from '../util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhanced code block plugin with copy button and language label.
|
|
||||||
*
|
|
||||||
* This plugin adds:
|
|
||||||
* - Language label display in the top-right corner
|
|
||||||
* - Copy to clipboard button
|
|
||||||
* - Enhanced visual styling for code blocks
|
|
||||||
*/
|
|
||||||
export const codeblockEnhanced = (): Extension => [
|
|
||||||
codeBlockEnhancedPlugin,
|
|
||||||
enhancedTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget for code block info bar (language + copy button).
|
|
||||||
*/
|
|
||||||
class CodeBlockInfoWidget extends WidgetType {
|
|
||||||
constructor(
|
|
||||||
readonly language: string,
|
|
||||||
readonly code: string
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: CodeBlockInfoWidget) {
|
|
||||||
return other.language === this.language && other.code === this.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.className = 'cm-code-block-info';
|
|
||||||
|
|
||||||
// Language label
|
|
||||||
if (this.language) {
|
|
||||||
const langLabel = document.createElement('span');
|
|
||||||
langLabel.className = 'cm-code-block-lang';
|
|
||||||
langLabel.textContent = this.language.toUpperCase();
|
|
||||||
container.appendChild(langLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy button
|
|
||||||
const copyButton = document.createElement('button');
|
|
||||||
copyButton.className = 'cm-code-block-copy-btn';
|
|
||||||
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">
|
|
||||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
|
|
||||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
copyButton.onclick = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(this.code);
|
|
||||||
// Visual feedback
|
|
||||||
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">
|
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
setTimeout(() => {
|
|
||||||
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">
|
|
||||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
|
|
||||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy code:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.appendChild(copyButton);
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin to add enhanced code block features.
|
|
||||||
*/
|
|
||||||
class CodeBlockEnhancedPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = this.buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
|
||||||
this.decorations = this.buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildDecorations(view: EditorView): DecorationSet {
|
|
||||||
const widgets: Array<ReturnType<Decoration['range']>> = [];
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter: (node) => {
|
|
||||||
if (node.name !== 'FencedCode') return;
|
|
||||||
|
|
||||||
// Skip if cursor is in this code block
|
|
||||||
if (isCursorInRange(view.state, [node.from, node.to])) return;
|
|
||||||
|
|
||||||
// Extract language
|
|
||||||
let language = '';
|
|
||||||
const codeInfoNode = node.node.getChild('CodeInfo');
|
|
||||||
if (codeInfoNode) {
|
|
||||||
language = view.state.doc
|
|
||||||
.sliceString(codeInfoNode.from, codeInfoNode.to)
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract code content (excluding fence markers)
|
|
||||||
const firstLine = view.state.doc.lineAt(node.from);
|
|
||||||
const lastLine = view.state.doc.lineAt(node.to);
|
|
||||||
const codeStart = firstLine.to + 1;
|
|
||||||
const codeEnd = lastLine.from - 1;
|
|
||||||
const code = view.state.doc.sliceString(codeStart, codeEnd);
|
|
||||||
|
|
||||||
// Add info widget at the first line
|
|
||||||
const infoWidget = Decoration.widget({
|
|
||||||
widget: new CodeBlockInfoWidget(language, code),
|
|
||||||
side: 1
|
|
||||||
});
|
|
||||||
widgets.push(infoWidget.range(firstLine.to));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decoration.set(widgets, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.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: 'var(--voidraft-font-mono)',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
fontWeight: '500',
|
|
||||||
letterSpacing: '0.02em',
|
|
||||||
color: 'var(--cm-codeblock-lang-color)'
|
|
||||||
},
|
|
||||||
'.cm-code-block-copy-btn': {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderRadius: '0.2rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
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-codeblock-btn-hover-bg)',
|
|
||||||
color: 'var(--cm-codeblock-btn-hover-color)'
|
|
||||||
},
|
|
||||||
'.cm-code-block-copy-btn svg': {
|
|
||||||
width: '14px',
|
|
||||||
height: '14px'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -4,154 +4,315 @@ import {
|
|||||||
DecorationSet,
|
DecorationSet,
|
||||||
Decoration,
|
Decoration,
|
||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { isCursorInRange } from '../util';
|
||||||
import { codeblock as classes } from '../classes';
|
import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes';
|
||||||
|
|
||||||
/**
|
/** Code block node types in syntax tree */
|
||||||
* Code block types to match in the syntax tree.
|
|
||||||
*/
|
|
||||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
||||||
|
|
||||||
|
/** Copy button icon SVGs (size controlled by CSS) */
|
||||||
|
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||||
|
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||||
|
|
||||||
|
/** Cache for code block metadata */
|
||||||
|
interface CodeBlockData {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
language: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code block plugin with optimized decoration building.
|
* Code block extension with language label and copy button.
|
||||||
*
|
*
|
||||||
* This plugin:
|
* Features:
|
||||||
* - Adds styling to code blocks (begin/end markers)
|
* - Adds background styling to code blocks
|
||||||
* - Hides code markers and language info when cursor is outside
|
* - Shows language label + copy button when language is specified
|
||||||
|
* - Hides markers when cursor is outside block
|
||||||
|
* - Optimized with viewport-only rendering
|
||||||
*/
|
*/
|
||||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build code block decorations.
|
* Widget for displaying language label and copy button.
|
||||||
* Uses array + Decoration.set() for automatic sorting.
|
* Uses ignoreEvent: true to prevent editor focus changes.
|
||||||
*/
|
*/
|
||||||
function buildCodeBlockDecorations(view: EditorView): DecorationSet {
|
class CodeBlockInfoWidget extends WidgetType {
|
||||||
const decorations: Range<Decoration>[] = [];
|
constructor(readonly data: CodeBlockData) {
|
||||||
const visited = new Set<string>();
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: CodeBlockInfoWidget): boolean {
|
||||||
|
return other.data.from === this.data.from &&
|
||||||
|
other.data.language === this.data.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.className = enhancedClasses.info;
|
||||||
|
container.dataset.codeFrom = String(this.data.from);
|
||||||
|
|
||||||
|
// Language label
|
||||||
|
const lang = document.createElement('span');
|
||||||
|
lang.className = enhancedClasses.lang;
|
||||||
|
lang.textContent = this.data.language;
|
||||||
|
|
||||||
|
// Copy button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = enhancedClasses.copyBtn;
|
||||||
|
btn.title = 'Copy';
|
||||||
|
btn.innerHTML = ICON_COPY;
|
||||||
|
btn.dataset.codeContent = this.data.content;
|
||||||
|
|
||||||
|
container.append(lang, btn);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical: ignore all events to prevent editor focus
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract language from code block node.
|
||||||
|
*/
|
||||||
|
function getLanguage(view: EditorView, node: any, offset: number): string | null {
|
||||||
|
let lang: string | null = null;
|
||||||
|
node.toTree().iterate({
|
||||||
|
enter: ({ type, from, to }) => {
|
||||||
|
if (type.name === 'CodeInfo') {
|
||||||
|
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract code content (without fence markers).
|
||||||
|
*/
|
||||||
|
function getCodeContent(view: EditorView, from: number, to: number): string {
|
||||||
|
const lines = view.state.doc.sliceString(from, to).split('\n');
|
||||||
|
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for visible code blocks.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const blocks = new Map<number, CodeBlockData>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
// Process only visible ranges
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
if (!CODE_BLOCK_TYPES.includes(type.name as typeof CODE_BLOCK_TYPES[number])) {
|
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid processing the same code block multiple times
|
|
||||||
const key = `${nodeFrom}:${nodeTo}`;
|
const key = `${nodeFrom}:${nodeTo}`;
|
||||||
if (visited.has(key)) return;
|
if (seen.has(key)) return;
|
||||||
visited.add(key);
|
seen.add(key);
|
||||||
|
|
||||||
const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
if (inBlock) return;
|
||||||
|
|
||||||
// Add line decorations for each line in the code block
|
const language = getLanguage(view, node, nodeFrom);
|
||||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
const endLine = view.state.doc.lineAt(nodeTo);
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
|
||||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
// Line decorations
|
||||||
const line = view.state.doc.line(lineNum);
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
|
const line = view.state.doc.line(num);
|
||||||
// Determine line position class(es)
|
const pos: string[] = [];
|
||||||
const isFirst = lineNum === startLine.number;
|
if (num === startLine.number) pos.push(classes.widgetBegin);
|
||||||
const isLast = lineNum === endLine.number;
|
if (num === endLine.number) pos.push(classes.widgetEnd);
|
||||||
|
|
||||||
// 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(
|
decorations.push(
|
||||||
Decoration.line({
|
Decoration.line({
|
||||||
class: `${classes.widget} ${positionClasses.join(' ')}`.trim()
|
class: `${classes.widget} ${pos.join(' ')}`.trim()
|
||||||
}).range(line.from)
|
}).range(line.from)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide code markers when cursor is outside the block
|
// Info widget (only if language specified)
|
||||||
if (!cursorInBlock) {
|
if (language) {
|
||||||
const codeBlock = node.toTree();
|
const content = getCodeContent(view, nodeFrom, nodeTo);
|
||||||
codeBlock.iterate({
|
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
||||||
enter: ({ type: childType, from: childFrom, to: childTo }) => {
|
blocks.set(nodeFrom, data);
|
||||||
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
|
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.replace({}).range(
|
Decoration.widget({
|
||||||
nodeFrom + childFrom,
|
widget: new CodeBlockInfoWidget(data),
|
||||||
nodeFrom + childTo
|
side: 1
|
||||||
)
|
}).range(startLine.to)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide markers
|
||||||
|
node.toTree().iterate({
|
||||||
|
enter: ({ type: t, from: f, to: t2 }) => {
|
||||||
|
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
|
||||||
|
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
return { decorations: Decoration.set(decorations, true), blocks };
|
||||||
return Decoration.set(decorations, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code block plugin class with optimized update detection.
|
* Handle copy button click.
|
||||||
*/
|
*/
|
||||||
class CodeBlockPlugin {
|
function handleCopyClick(btn: HTMLButtonElement): void {
|
||||||
decorations: DecorationSet;
|
const content = btn.dataset.codeContent;
|
||||||
private lastSelection: number = -1;
|
if (!content) return;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
this.decorations = buildCodeBlockDecorations(view);
|
btn.innerHTML = ICON_CHECK;
|
||||||
this.lastSelection = view.state.selection.main.head;
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = ICON_COPY;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
/**
|
||||||
const docChanged = update.docChanged;
|
* Code block plugin with optimized updates.
|
||||||
const viewportChanged = update.viewportChanged;
|
*/
|
||||||
const selectionChanged = update.selectionSet;
|
class CodeBlockPluginClass {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
blocks: Map<number, CodeBlockData>;
|
||||||
|
private lastHead = -1;
|
||||||
|
|
||||||
// Optimization: check if selection moved to a different line
|
constructor(view: EditorView) {
|
||||||
if (selectionChanged && !docChanged && !viewportChanged) {
|
const result = buildDecorations(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.blocks = result.blocks;
|
||||||
|
this.lastHead = view.state.selection.main.head;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate): void {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
// Skip rebuild if cursor stayed on same line
|
||||||
|
if (selectionSet && !docChanged && !viewportChanged) {
|
||||||
const newHead = update.state.selection.main.head;
|
const newHead = update.state.selection.main.head;
|
||||||
const oldHead = this.lastSelection;
|
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
|
||||||
|
const newLine = update.state.doc.lineAt(newHead).number;
|
||||||
|
|
||||||
const oldLine = update.startState.doc.lineAt(oldHead);
|
if (oldLine === newLine) {
|
||||||
const newLine = update.state.doc.lineAt(newHead);
|
this.lastHead = newHead;
|
||||||
|
|
||||||
if (oldLine.number === newLine.number) {
|
|
||||||
this.lastSelection = newHead;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (docChanged || viewportChanged || selectionChanged) {
|
if (docChanged || viewportChanged || selectionSet) {
|
||||||
this.decorations = buildCodeBlockDecorations(update.view);
|
const result = buildDecorations(update.view);
|
||||||
this.lastSelection = update.state.selection.main.head;
|
this.decorations = result.decorations;
|
||||||
|
this.blocks = result.blocks;
|
||||||
|
this.lastHead = update.state.selection.main.head;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
|
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations,
|
||||||
|
|
||||||
|
eventHandlers: {
|
||||||
|
// Handle copy button clicks without triggering editor focus
|
||||||
|
mousedown(e: MouseEvent, view: EditorView) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Check if clicked on copy button or its SVG child
|
||||||
|
const btn = target.closest(`.${enhancedClasses.copyBtn}`) as HTMLButtonElement;
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyClick(btn);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if clicked on info container (language label)
|
||||||
|
if (target.closest(`.${enhancedClasses.info}`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for code blocks.
|
* Base theme for code blocks.
|
||||||
* Uses CSS variables from variables.css for consistent theming.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
[`.${classes.widget}`]: {
|
[`.${classes.widget}`]: {
|
||||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
backgroundColor: 'var(--cm-codeblock-bg)'
|
||||||
},
|
},
|
||||||
[`.${classes.widgetBegin}`]: {
|
[`.${classes.widgetBegin}`]: {
|
||||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderTopRightRadius: 'var(--cm-codeblock-radius)'
|
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
|
position: 'relative',
|
||||||
|
borderTop: '1px solid var(--text-primary)'
|
||||||
},
|
},
|
||||||
[`.${classes.widgetEnd}`]: {
|
[`.${classes.widgetEnd}`]: {
|
||||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
|
borderBottom: '1px solid var(--text-primary)'
|
||||||
|
},
|
||||||
|
// Info container
|
||||||
|
[`.${enhancedClasses.info}`]: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '8px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5em',
|
||||||
|
zIndex: '5',
|
||||||
|
opacity: '0.5',
|
||||||
|
transition: 'opacity 0.15s'
|
||||||
|
},
|
||||||
|
[`.${enhancedClasses.info}:hover`]: {
|
||||||
|
opacity: '1'
|
||||||
|
},
|
||||||
|
// Language label
|
||||||
|
[`.${enhancedClasses.lang}`]: {
|
||||||
|
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
userSelect: 'none'
|
||||||
|
},
|
||||||
|
// Copy button
|
||||||
|
[`.${enhancedClasses.copyBtn}`]: {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0.15em',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: '0.7',
|
||||||
|
transition: 'opacity 0.15s, background 0.15s'
|
||||||
|
},
|
||||||
|
[`.${enhancedClasses.copyBtn}:hover`]: {
|
||||||
|
opacity: '1',
|
||||||
|
background: 'rgba(128, 128, 128, 0.2)'
|
||||||
|
},
|
||||||
|
[`.${enhancedClasses.copyBtn} svg`]: {
|
||||||
|
width: '1em',
|
||||||
|
height: '1em'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
|||||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
if (type.name !== 'InlineCode') return;
|
if (type.name !== 'InlineCode') return;
|
||||||
|
|
||||||
|
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
|
||||||
|
// Skip background decoration when cursor is in the code
|
||||||
|
// This allows selection highlighting to be visible when editing
|
||||||
|
if (cursorInCode) return;
|
||||||
|
|
||||||
// Get the actual code content (excluding backticks)
|
// Get the actual code content (excluding backticks)
|
||||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||||
|
|
||||||
@@ -55,12 +61,10 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
|||||||
|
|
||||||
// Only add decoration if there's actual content
|
// Only add decoration if there's actual content
|
||||||
if (codeStart < codeEnd) {
|
if (codeStart < codeEnd) {
|
||||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
|
|
||||||
// Add mark decoration for the code content
|
// Add mark decoration for the code content
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.mark({
|
Decoration.mark({
|
||||||
class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : 'cm-inline-code'
|
class: 'cm-inline-code'
|
||||||
}).range(codeStart, codeEnd)
|
}).range(codeStart, codeEnd)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,10 +107,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
padding: '0.1rem 0.3rem',
|
padding: '0.1rem 0.3rem',
|
||||||
fontFamily: 'var(--voidraft-font-mono)',
|
fontFamily: 'var(--voidraft-font-mono)',
|
||||||
fontSize: '0.9em'
|
fontSize: '0.9em'
|
||||||
},
|
|
||||||
'.cm-inline-code-active': {
|
|
||||||
// Slightly different style when cursor is inside
|
|
||||||
backgroundColor: 'var(--cm-inline-code-bg)'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { isCursorInRange } from '../util';
|
|
||||||
import mermaid from 'mermaid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mermaid diagram preview plugin.
|
|
||||||
*
|
|
||||||
* This plugin detects mermaid code blocks and renders them as SVG diagrams.
|
|
||||||
* Features:
|
|
||||||
* - Detects ```mermaid code blocks
|
|
||||||
* - Renders mermaid diagrams as inline SVG
|
|
||||||
* - Shows the original code when cursor is in the block
|
|
||||||
* - Caches rendered diagrams for performance
|
|
||||||
* - Supports theme switching (dark/light)
|
|
||||||
* - Supports all mermaid diagram types (flowchart, sequence, etc.)
|
|
||||||
*/
|
|
||||||
export const mermaidPreview = (): Extension => [
|
|
||||||
mermaidPlugin,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
// Current mermaid theme
|
|
||||||
let currentMermaidTheme: 'default' | 'dark' = 'default';
|
|
||||||
let mermaidInitialized = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the current theme from the DOM.
|
|
||||||
*/
|
|
||||||
function detectTheme(): 'default' | 'dark' {
|
|
||||||
const dataTheme = document.documentElement.getAttribute('data-theme');
|
|
||||||
|
|
||||||
if (dataTheme === 'light') {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataTheme === 'dark') {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
// For 'auto', check system preference
|
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize mermaid with the specified theme.
|
|
||||||
*/
|
|
||||||
function initMermaid(theme: 'default' | 'dark' = currentMermaidTheme) {
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
theme,
|
|
||||||
securityLevel: 'strict',
|
|
||||||
flowchart: {
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: 'basis'
|
|
||||||
},
|
|
||||||
sequence: {
|
|
||||||
showSequenceNumbers: false
|
|
||||||
},
|
|
||||||
logLevel: 'error'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentMermaidTheme = theme;
|
|
||||||
mermaidInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Information about a mermaid code block.
|
|
||||||
*/
|
|
||||||
interface MermaidBlockInfo {
|
|
||||||
/** Start position of the code block */
|
|
||||||
from: number;
|
|
||||||
/** End position of the code block */
|
|
||||||
to: number;
|
|
||||||
/** The mermaid code content */
|
|
||||||
code: string;
|
|
||||||
/** Unique ID for rendering */
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for rendered mermaid diagrams.
|
|
||||||
* Key is `${theme}:${code}` to support theme-specific caching.
|
|
||||||
*/
|
|
||||||
const renderCache = new Map<string, string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate cache key for a diagram.
|
|
||||||
*/
|
|
||||||
function getCacheKey(code: string): string {
|
|
||||||
return `${currentMermaidTheme}:${code}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique ID for a mermaid diagram.
|
|
||||||
*/
|
|
||||||
let idCounter = 0;
|
|
||||||
function generateId(): string {
|
|
||||||
return `mermaid-${Date.now()}-${idCounter++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract mermaid code blocks from the visible ranges.
|
|
||||||
*/
|
|
||||||
function extractMermaidBlocks(view: EditorView): MermaidBlockInfo[] {
|
|
||||||
const blocks: MermaidBlockInfo[] = [];
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter: (node) => {
|
|
||||||
if (node.name !== 'FencedCode') return;
|
|
||||||
|
|
||||||
// Check if this is a mermaid code block
|
|
||||||
const codeInfoNode = node.node.getChild('CodeInfo');
|
|
||||||
if (!codeInfoNode) return;
|
|
||||||
|
|
||||||
const language = view.state.doc
|
|
||||||
.sliceString(codeInfoNode.from, codeInfoNode.to)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (language !== 'mermaid') return;
|
|
||||||
|
|
||||||
// Extract the code content
|
|
||||||
const firstLine = view.state.doc.lineAt(node.from);
|
|
||||||
const lastLine = view.state.doc.lineAt(node.to);
|
|
||||||
const codeStart = firstLine.to + 1;
|
|
||||||
const codeEnd = lastLine.from - 1;
|
|
||||||
|
|
||||||
if (codeStart >= codeEnd) return;
|
|
||||||
|
|
||||||
const code = view.state.doc.sliceString(codeStart, codeEnd).trim();
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
blocks.push({
|
|
||||||
from: node.from,
|
|
||||||
to: node.to,
|
|
||||||
code,
|
|
||||||
id: generateId()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mermaid preview widget that renders the diagram.
|
|
||||||
*/
|
|
||||||
class MermaidPreviewWidget extends WidgetType {
|
|
||||||
private svg: string | null = null;
|
|
||||||
private error: string | null = null;
|
|
||||||
private rendering = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly code: string,
|
|
||||||
readonly blockId: string
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
// Check cache first (theme-specific)
|
|
||||||
const cached = renderCache.get(getCacheKey(code));
|
|
||||||
if (cached) {
|
|
||||||
this.svg = cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: MermaidPreviewWidget): boolean {
|
|
||||||
return other.code === this.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView): HTMLElement {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.className = 'cm-mermaid-preview';
|
|
||||||
|
|
||||||
if (this.svg) {
|
|
||||||
// Use cached SVG
|
|
||||||
container.innerHTML = this.svg;
|
|
||||||
this.setupSvgStyles(container);
|
|
||||||
} else if (this.error) {
|
|
||||||
// Show error
|
|
||||||
const errorEl = document.createElement('div');
|
|
||||||
errorEl.className = 'cm-mermaid-error';
|
|
||||||
errorEl.textContent = `Mermaid Error: ${this.error}`;
|
|
||||||
container.appendChild(errorEl);
|
|
||||||
} else {
|
|
||||||
// Show loading and start rendering
|
|
||||||
const loading = document.createElement('div');
|
|
||||||
loading.className = 'cm-mermaid-loading';
|
|
||||||
loading.textContent = 'Rendering diagram...';
|
|
||||||
container.appendChild(loading);
|
|
||||||
|
|
||||||
// Render asynchronously
|
|
||||||
if (!this.rendering) {
|
|
||||||
this.rendering = true;
|
|
||||||
this.renderMermaid(container, view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderMermaid(container: HTMLElement, view: EditorView) {
|
|
||||||
// Ensure mermaid is initialized with current theme
|
|
||||||
const theme = detectTheme();
|
|
||||||
if (!mermaidInitialized || currentMermaidTheme !== theme) {
|
|
||||||
initMermaid(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { svg } = await mermaid.render(this.blockId, this.code);
|
|
||||||
|
|
||||||
// Cache the result with theme-specific key
|
|
||||||
renderCache.set(getCacheKey(this.code), svg);
|
|
||||||
this.svg = svg;
|
|
||||||
|
|
||||||
// Update the container
|
|
||||||
container.innerHTML = svg;
|
|
||||||
container.classList.remove('cm-mermaid-loading');
|
|
||||||
this.setupSvgStyles(container);
|
|
||||||
|
|
||||||
// Trigger a re-render to update decorations
|
|
||||||
view.dispatch({});
|
|
||||||
} catch (err) {
|
|
||||||
this.error = err instanceof Error ? err.message : String(err);
|
|
||||||
|
|
||||||
// Clear the loading state and show error
|
|
||||||
container.innerHTML = '';
|
|
||||||
const errorEl = document.createElement('div');
|
|
||||||
errorEl.className = 'cm-mermaid-error';
|
|
||||||
errorEl.textContent = `Mermaid Error: ${this.error}`;
|
|
||||||
container.appendChild(errorEl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupSvgStyles(container: HTMLElement) {
|
|
||||||
const svg = container.querySelector('svg');
|
|
||||||
if (svg) {
|
|
||||||
svg.style.maxWidth = '100%';
|
|
||||||
svg.style.height = 'auto';
|
|
||||||
svg.removeAttribute('height');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build decorations for mermaid code blocks.
|
|
||||||
*/
|
|
||||||
function buildMermaidDecorations(view: EditorView): DecorationSet {
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
|
||||||
const blocks = extractMermaidBlocks(view);
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
// Skip if cursor is in this code block
|
|
||||||
if (isCursorInRange(view.state, [block.from, block.to])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add preview widget after the code block
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new MermaidPreviewWidget(block.code, block.id),
|
|
||||||
side: 1
|
|
||||||
}).range(block.to)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track the last known theme for change detection.
|
|
||||||
*/
|
|
||||||
let lastTheme: 'default' | 'dark' = detectTheme();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mermaid preview plugin class.
|
|
||||||
*/
|
|
||||||
class MermaidPreviewPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private lastSelectionHead: number = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
// Initialize mermaid with detected theme
|
|
||||||
lastTheme = detectTheme();
|
|
||||||
initMermaid(lastTheme);
|
|
||||||
this.decorations = buildMermaidDecorations(view);
|
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
// Check if theme changed
|
|
||||||
const currentTheme = detectTheme();
|
|
||||||
if (currentTheme !== lastTheme) {
|
|
||||||
lastTheme = currentTheme;
|
|
||||||
// Theme changed, clear cache and reinitialize
|
|
||||||
renderCache.clear();
|
|
||||||
initMermaid(currentTheme);
|
|
||||||
this.decorations = buildMermaidDecorations(update.view);
|
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.decorations = buildMermaidDecorations(update.view);
|
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.selectionSet) {
|
|
||||||
const newHead = update.state.selection.main.head;
|
|
||||||
if (newHead !== this.lastSelectionHead) {
|
|
||||||
this.decorations = buildMermaidDecorations(update.view);
|
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mermaidPlugin = ViewPlugin.fromClass(MermaidPreviewPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for mermaid preview.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-mermaid-preview': {
|
|
||||||
display: 'block',
|
|
||||||
backgroundColor: 'var(--cm-mermaid-bg, rgba(128, 128, 128, 0.05))',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
overflow: 'auto',
|
|
||||||
textAlign: 'center'
|
|
||||||
},
|
|
||||||
'.cm-mermaid-preview svg': {
|
|
||||||
maxWidth: '100%',
|
|
||||||
height: 'auto'
|
|
||||||
},
|
|
||||||
'.cm-mermaid-loading': {
|
|
||||||
color: 'var(--cm-foreground)',
|
|
||||||
opacity: '0.6',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
'.cm-mermaid-error': {
|
|
||||||
color: 'var(--cm-error, #ef4444)',
|
|
||||||
backgroundColor: 'var(--cm-error-bg, rgba(239, 68, 68, 0.1))',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
textAlign: 'left',
|
|
||||||
fontFamily: 'var(--voidraft-font-mono)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the mermaid render cache.
|
|
||||||
* Call this when theme changes to re-render diagrams.
|
|
||||||
*/
|
|
||||||
export function clearMermaidCache(): void {
|
|
||||||
renderCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update mermaid theme based on current system theme.
|
|
||||||
* Call this when the application theme changes.
|
|
||||||
*/
|
|
||||||
export function refreshMermaidTheme(): void {
|
|
||||||
const theme = detectTheme();
|
|
||||||
if (theme !== currentMermaidTheme) {
|
|
||||||
renderCache.clear();
|
|
||||||
initMermaid(theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force refresh all mermaid diagrams.
|
|
||||||
* Clears cache and reinitializes with current theme.
|
|
||||||
*/
|
|
||||||
export function forceRefreshMermaid(): void {
|
|
||||||
renderCache.clear();
|
|
||||||
initMermaid(detectTheme());
|
|
||||||
}
|
|
||||||
145
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
145
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { isCursorInRange } from '../util';
|
||||||
|
import { extractTablesFromState } from '../state/table';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table styling extension for Markdown.
|
||||||
|
*
|
||||||
|
* - Adds background styling and hides syntax when cursor is OUTSIDE table
|
||||||
|
* - Shows raw markdown with NO styling when cursor is INSIDE table (edit mode)
|
||||||
|
*/
|
||||||
|
export const table = (): Extension => [tablePlugin, baseTheme];
|
||||||
|
|
||||||
|
/** Line decorations - only applied when NOT editing */
|
||||||
|
const headerLine = Decoration.line({ attributes: { class: 'cm-table-header' } });
|
||||||
|
const delimiterLine = Decoration.line({ attributes: { class: 'cm-table-delimiter' } });
|
||||||
|
const dataLine = Decoration.line({ attributes: { class: 'cm-table-data' } });
|
||||||
|
|
||||||
|
/** Mark to hide pipe characters */
|
||||||
|
const pipeHidden = Decoration.mark({ attributes: { class: 'cm-table-pipe-hidden' } });
|
||||||
|
|
||||||
|
/** Mark to hide delimiter row content */
|
||||||
|
const delimiterHidden = Decoration.mark({ attributes: { class: 'cm-table-delimiter-hidden' } });
|
||||||
|
|
||||||
|
/** Delimiter row regex */
|
||||||
|
const DELIMITER_REGEX = /^\s*\|?\s*[-:]+/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for tables.
|
||||||
|
* Only adds decorations when cursor is OUTSIDE the table.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const tables = extractTablesFromState(view.state);
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
// Skip all decorations if cursor is inside table (edit mode)
|
||||||
|
if (isCursorInRange(view.state, [table.from, table.to])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLine = view.state.doc.lineAt(table.from);
|
||||||
|
const endLine = view.state.doc.lineAt(table.to);
|
||||||
|
const lines = table.rawText.split('\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const lineNum = startLine.number + i;
|
||||||
|
if (lineNum > endLine.number) break;
|
||||||
|
|
||||||
|
const line = view.state.doc.line(lineNum);
|
||||||
|
const text = lines[i];
|
||||||
|
const isDelimiter = i === 1 && DELIMITER_REGEX.test(text);
|
||||||
|
|
||||||
|
// Add line decoration
|
||||||
|
if (i === 0) {
|
||||||
|
decorations.push(headerLine.range(line.from));
|
||||||
|
} else if (isDelimiter) {
|
||||||
|
decorations.push(delimiterLine.range(line.from));
|
||||||
|
} else {
|
||||||
|
decorations.push(dataLine.range(line.from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide syntax elements
|
||||||
|
if (isDelimiter) {
|
||||||
|
// Hide entire delimiter content
|
||||||
|
decorations.push(delimiterHidden.range(line.from, line.to));
|
||||||
|
} else {
|
||||||
|
// Hide pipe characters
|
||||||
|
for (let j = 0; j < text.length; j++) {
|
||||||
|
if (text[j] === '|') {
|
||||||
|
decorations.push(pipeHidden.range(line.from + j, line.from + j + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table ViewPlugin.
|
||||||
|
*/
|
||||||
|
const tablePlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ decorations: v => v.decorations }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for table styling.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
// Header row
|
||||||
|
'.cm-table-header': {
|
||||||
|
backgroundColor: 'var(--cm-table-header-bg, rgba(128, 128, 128, 0.12))',
|
||||||
|
borderTopLeftRadius: '4px',
|
||||||
|
borderTopRightRadius: '4px'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delimiter row
|
||||||
|
'.cm-table-delimiter': {
|
||||||
|
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))',
|
||||||
|
lineHeight: '0.5'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
'.cm-table-data': {
|
||||||
|
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))'
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table-data:last-of-type': {
|
||||||
|
borderBottomLeftRadius: '4px',
|
||||||
|
borderBottomRightRadius: '4px'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden pipe characters
|
||||||
|
'.cm-table-pipe-hidden': {
|
||||||
|
fontSize: '0',
|
||||||
|
color: 'transparent'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden delimiter content
|
||||||
|
'.cm-table-delimiter-hidden': {
|
||||||
|
fontSize: '0',
|
||||||
|
color: 'transparent'
|
||||||
|
}
|
||||||
|
});
|
||||||
42
frontend/src/views/editor/extensions/markdown/state/table.ts
Normal file
42
frontend/src/views/editor/extensions/markdown/state/table.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic table information extracted from syntax tree.
|
||||||
|
*/
|
||||||
|
export interface TableInfo {
|
||||||
|
/** Starting position in document */
|
||||||
|
from: number;
|
||||||
|
/** End position in document */
|
||||||
|
to: number;
|
||||||
|
/** Raw markdown text */
|
||||||
|
rawText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all tables from the editor state.
|
||||||
|
*/
|
||||||
|
export function extractTablesFromState(state: EditorState): TableInfo[] {
|
||||||
|
const tables: TableInfo[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter: ({ type, from, to }) => {
|
||||||
|
if (type.name !== 'Table') return;
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const key = `${from}:${to}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
const rawText = state.doc.sliceString(from, to);
|
||||||
|
|
||||||
|
// Need at least 2 lines (header + delimiter)
|
||||||
|
if (rawText.split('\n').length < 2) return;
|
||||||
|
|
||||||
|
tables.push({ from, to, rawText });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user