🚧 Added support for markdown preview table

This commit is contained in:
2025-12-04 00:47:51 +08:00
parent 91f4f4afac
commit 71ca541f78
21 changed files with 3120 additions and 1263 deletions

View File

@@ -1,208 +1,348 @@
/**
* HTML plugin for CodeMirror.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
import { syntaxTree } from '@codemirror/language';
import { EditorState, Range } from '@codemirror/state';
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import {
Decoration,
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
showTooltip,
Tooltip
} from '@codemirror/view';
import DOMPurify from 'dompurify';
import { isCursorInRange } from '../util';
import { LruCache } from '@/common/utils/lruCache';
interface EmbedBlockData {
interface HTMLBlockInfo {
from: number;
to: number;
content: string;
sanitized: string;
}
/**
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
* Returns all blocks regardless of cursor position.
*/
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
const blocks = new Array<EmbedBlockData>();
syntaxTree(state).iterate({
enter({ from, to, name }) {
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html);
// Skip empty content after sanitization
if (!content.trim()) return;
blocks.push({ from, to, content });
}
});
return blocks;
}
// HTML5 official logo
const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
/**
* Build decorations for HTML blocks.
* Only shows preview for blocks where cursor is not inside.
* LRU cache for DOMPurify sanitize results.
*/
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const block of blocks) {
// Skip if cursor is in range
if (isCursorInRange(state, [block.from, block.to])) continue;
// Hide the original HTML source code
decorations.push(Decoration.replace({}).range(block.from, block.to));
// Add the preview widget at the end
decorations.push(
Decoration.widget({
widget: new HTMLBlockWidget(block),
side: 1
}).range(block.to)
);
const sanitizeCache = new LruCache<string, string>(100);
/**
* Sanitize HTML content with caching for performance.
*/
function sanitizeHTML(html: string): string {
const cached = sanitizeCache.get(html);
if (cached !== undefined) {
return cached;
}
return Decoration.set(decorations, true);
const sanitized = DOMPurify.sanitize(html, {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
ALLOW_DATA_ATTR: true
});
sanitizeCache.set(html, sanitized);
return sanitized;
}
/**
* Check if selection affects any HTML block (cursor moved in/out of a block).
* Check if document changes affect any of the given regions.
*/
function selectionAffectsBlocks(
state: EditorState,
prevState: EditorState,
blocks: EmbedBlockData[]
): boolean {
for (const block of blocks) {
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
const isInRange = isCursorInRange(state, [block.from, block.to]);
if (wasInRange !== isInRange) return true;
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
if (regions.length === 0) return true;
let affected = false;
changes.iterChanges((fromA, toA) => {
if (affected) return;
for (const region of regions) {
if (fromA <= region.to && toA >= region.from) {
affected = true;
return;
}
}
});
return affected;
}
/**
* Check if a node is inside a table.
*/
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
let current = node.parent;
while (current) {
const name = current.type.name;
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
return true;
}
current = current.parent as typeof current;
}
return false;
}
/**
* ViewPlugin for HTML block preview.
* Uses smart caching to avoid unnecessary updates during text selection.
* Extract all HTML blocks from visible ranges.
* Excludes HTML inside tables (tables have their own rendering).
*/
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
const result: HTMLBlockInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (nodeRef) => {
const { name, from: f, to: t, node } = nodeRef;
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
// Skip HTML inside tables
if (isInsideTable(node)) return;
const content = view.state.sliceDoc(f, t);
const sanitized = sanitizeHTML(content);
// Skip empty content after sanitization
if (!sanitized.trim()) return;
result.push({ from: f, to: t, content, sanitized });
}
});
}
return result;
}
/** Effect to toggle tooltip visibility */
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
/** Effect to close tooltip */
const closeHTMLTooltip = StateEffect.define<null>();
/** StateField to track active tooltip */
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(toggleHTMLTooltip)) {
// Toggle: if same block, close; otherwise open new
if (value && effect.value && value.from === effect.value.from) {
return null;
}
return effect.value;
}
if (effect.is(closeHTMLTooltip)) {
return null;
}
}
// Close tooltip on document changes
if (tr.docChanged) {
return null;
}
return value;
},
provide: (field) =>
showTooltip.from(field, (block): Tooltip | null => {
if (!block) return null;
return {
pos: block.to,
above: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-html-tooltip';
dom.innerHTML = block.sanitized;
// Prevent clicks inside tooltip from closing it
dom.addEventListener('click', (e) => {
e.stopPropagation();
});
return { dom };
}
};
})
});
/**
* Indicator widget shown at the end of HTML blocks.
* Clicking toggles the tooltip.
*/
class HTMLIndicatorWidget extends WidgetType {
constructor(readonly info: HTMLBlockInfo) {
super();
}
toDOM(view: EditorView): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-html-indicator';
el.innerHTML = HTML_ICON;
el.title = 'Click to preview HTML';
// Click handler to toggle tooltip
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: toggleHTMLTooltip.of(this.info)
});
});
return el;
}
eq(other: HTMLIndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.content === other.info.content;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Plugin to manage HTML block decorations.
* Optimized with incremental updates when changes don't affect HTML regions.
*/
class HTMLBlockPlugin {
decorations: DecorationSet;
blocks: EmbedBlockData[];
blocks: HTMLBlockInfo[] = [];
constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state);
this.decorations = buildDecorations(view.state, this.blocks);
this.blocks = extractHTMLBlocks(view);
this.decorations = this.build();
}
update(update: ViewUpdate) {
// If document changed, re-extract all blocks
if (update.docChanged) {
this.blocks = extractAllHTMLBlocks(update.state);
this.decorations = buildDecorations(update.state, this.blocks);
// Always rebuild on viewport change
if (update.viewportChanged) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
return;
}
// If selection changed, only rebuild if cursor moved in/out of a block
if (update.selectionSet) {
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
this.decorations = buildDecorations(update.state, this.blocks);
// For document changes, only rebuild if changes affect HTML regions
if (update.docChanged) {
const needsRebuild = changesAffectRegions(update.changes, this.blocks);
if (needsRebuild) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
} else {
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.blocks = this.blocks.map(block => ({
...block,
from: update.changes.mapPos(block.from),
to: update.changes.mapPos(block.to)
}));
}
}
}
private build(): DecorationSet {
const deco: Range<Decoration>[] = [];
for (const block of this.blocks) {
deco.push(
Decoration.widget({
widget: new HTMLIndicatorWidget(block),
side: 1
}).range(block.to)
);
}
return Decoration.set(deco, true);
}
}
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
decorations: (v) => v.decorations
});
class HTMLBlockWidget extends WidgetType {
constructor(public data: EmbedBlockData) {
super();
}
toDOM(view: EditorView): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cm-html-block-widget';
// Content container
const content = document.createElement('span');
content.className = 'cm-html-block-content';
// This is sanitized!
content.innerHTML = this.data.content;
// Edit button
const editBtn = document.createElement('button');
editBtn.className = 'cm-html-block-edit-btn';
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>`;
editBtn.title = 'Edit HTML';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
selection: { anchor: this.data.from }
});
view.focus();
});
wrapper.appendChild(content);
wrapper.appendChild(editBtn);
return wrapper;
}
eq(widget: HTMLBlockWidget): boolean {
return JSON.stringify(widget.data) === JSON.stringify(this.data);
}
}
/**
* Base theme for HTML blocks.
* Close tooltip when clicking outside.
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'inline-block',
position: 'relative',
maxWidth: '100%',
overflow: 'auto',
verticalAlign: 'middle'
const clickOutsideHandler = EditorView.domEventHandlers({
click(event, view) {
const target = event.target as HTMLElement;
// Don't close if clicking on indicator or inside tooltip
if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
return false;
}
// Close tooltip if one is open
const currentTooltip = view.state.field(htmlTooltipState);
if (currentTooltip) {
view.dispatch({
effects: closeHTMLTooltip.of(null)
});
}
return false;
}
});
const theme = EditorView.baseTheme({
// Indicator icon
'.cm-html-indicator': {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
verticalAlign: 'middle',
cursor: 'pointer',
opacity: '0.5',
color: 'var(--cm-html-color, #e44d26)',
transition: 'opacity 0.15s',
'& svg': { width: '14px', height: '14px' }
},
'.cm-html-block-content': {
display: 'inline-block'
'.cm-html-indicator:hover': {
opacity: '1'
},
// Ensure images are properly sized
'.cm-html-block-content img': {
// Tooltip content
'.cm-html-tooltip': {
padding: '8px 12px',
maxWidth: '60vw',
maxHeight: '50vh',
overflow: 'auto'
},
// Images inside tooltip
'.cm-html-tooltip img': {
maxWidth: '100%',
height: 'auto',
display: 'block'
},
'.cm-html-block-edit-btn': {
position: 'absolute',
top: '4px',
right: '4px',
padding: '4px',
border: 'none',
// Style the parent tooltip container
'.cm-tooltip:has(.cm-html-tooltip)': {
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #ddd)',
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)'
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];
/**
* HTML block extension.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
export const htmlBlockExtension: Extension = [
htmlBlockPlugin,
htmlTooltipState,
clickOutsideHandler,
theme
];