')) html = html.replace(CODE_RE, '$1');
+ html = html.replace(LINK_RE, '$1').replace(STRIKE_RE, '$1');
+ html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
} else {
- // No HTML tags - escape < > and process markdown
html = html.replace(//g, '>');
-
- // Bold: **text** or __text__
- html = html.replace(BOLD_STAR_RE, '$1');
- html = html.replace(BOLD_UNDER_RE, '$1');
-
- // Italic: *text* or _text_ (but not inside words for _)
- html = html.replace(ITALIC_STAR_RE, '$1');
- html = html.replace(ITALIC_UNDER_RE, '$1');
-
- // Inline code: `code`
+ html = html.replace(BOLD_STAR_RE, '$1').replace(BOLD_UNDER_RE, '$1');
+ html = html.replace(ITALIC_STAR_RE, '$1').replace(ITALIC_UNDER_RE, '$1');
html = html.replace(CODE_RE, '$1');
-
- // Links: [text](url)
- html = html.replace(LINK_RE, '$1');
-
- // Strikethrough: ~~text~~
- html = html.replace(STRIKE_RE, '$1');
+ html = html.replace(LINK_RE, '$1').replace(STRIKE_RE, '$1');
}
-
- // Cache result using LRU cache
- inlineRenderCache.set(text, html);
-
return html;
}
+function parseRowText(rowText: string): string[] {
+ const cells: string[] = [];
+ let start = 0, end = rowText.length;
+ while (start < end && rowText.charCodeAt(start) <= 32) start++;
+ while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
+ if (start < end && rowText.charCodeAt(start) === 124) start++;
+ if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
+ let cellStart = start;
+ for (let i = start; i <= end; i++) {
+ if (i === end || rowText.charCodeAt(i) === 124) {
+ let cs = cellStart, ce = i;
+ while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
+ while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
+ cells.push(rowText.substring(cs, ce));
+ cellStart = i + 1;
+ }
+ }
+ return cells;
+}
+
+function parseAlignment(text: string): CellAlign {
+ const len = text.length;
+ if (len === 0) return 'left';
+ let start = 0, end = len - 1;
+ while (start < len && text.charCodeAt(start) === 32) start++;
+ while (end > start && text.charCodeAt(end) === 32) end--;
+ if (start > end) return 'left';
+ const hasLeft = text.charCodeAt(start) === 58;
+ const hasRight = text.charCodeAt(end) === 58;
+ if (hasLeft && hasRight) return 'center';
+ if (hasRight) return 'right';
+ return 'left';
+}
-/**
- * Widget to display rendered table.
- * Uses absolute positioning to overlay on source lines.
- * Optimized with innerHTML for faster DOM creation.
- */
class TableWidget extends WidgetType {
- // Cache the generated HTML to avoid regenerating on each toDOM call
- private cachedHTML: string | null = null;
-
- constructor(
- readonly tableData: TableData,
- readonly lineCount: number,
- readonly lineHeight: number,
- readonly visualHeight: number,
- readonly contentWidth: number
- ) {
- super();
+ constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
+ eq(other: TableWidget) {
+ if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
+ if (this.data === other.data) return true;
+ if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
+ for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
+ for (let i = 0; i < this.data.rows.length; i++) {
+ if (this.data.rows[i].length !== other.data.rows[i].length) return false;
+ for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
+ }
+ return true;
}
-
- /**
- * Build table HTML string (much faster than DOM API for large tables).
- */
- private buildTableHTML(): string {
- if (this.cachedHTML) return this.cachedHTML;
-
- // Calculate row heights
- const headerRatio = 2 / this.lineCount;
- const dataRowRatio = 1 / this.lineCount;
- const headerHeight = this.visualHeight * headerRatio;
- const dataRowHeight = this.visualHeight * dataRowRatio;
-
- // Build header cells
- const headerCells = this.tableData.headers.map((header, idx) => {
- const align = this.tableData.alignments[idx] || 'left';
- const escapedTitle = header.replace(/"/g, '"');
- return `${renderInlineMarkdown(header)} | `;
- }).join('');
-
- // Build body rows
- const bodyRows = this.tableData.rows.map(row => {
- const cells = row.map((cell, idx) => {
- const align = this.tableData.alignments[idx] || 'left';
- const escapedTitle = cell.replace(/"/g, '"');
- return `${renderInlineMarkdown(cell)} | `;
- }).join('');
- return `${cells}
`;
- }).join('');
-
- this.cachedHTML = `${headerCells}
${bodyRows}
`;
- return this.cachedHTML;
- }
-
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-table-container';
container.style.height = `${this.visualHeight}px`;
-
- const tableWrapper = document.createElement('div');
- tableWrapper.className = 'cm-table-wrapper';
- tableWrapper.style.maxWidth = `${this.contentWidth}px`;
- tableWrapper.style.maxHeight = `${this.visualHeight}px`;
-
- // Use innerHTML for faster DOM creation (single parse vs many createElement calls)
- tableWrapper.innerHTML = this.buildTableHTML();
-
- container.appendChild(tableWrapper);
+ const wrapper = document.createElement('div');
+ wrapper.className = 'cm-table-wrapper';
+ wrapper.style.maxWidth = `${this.contentWidth}px`;
+ wrapper.style.maxHeight = `${this.visualHeight}px`;
+ const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
+ const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
+ const headerCells = this.data.headers.map((h, i) => `${renderInlineMarkdown(h)} | `).join('');
+ const bodyRows = this.data.rows.map(row => `${row.map((c, i) => `| ${renderInlineMarkdown(c)} | `).join('')}
`).join('');
+ wrapper.innerHTML = `${headerCells}
${bodyRows}
`;
+ container.appendChild(wrapper);
return container;
}
-
- eq(other: TableWidget): boolean {
- // Quick dimension checks first (most likely to differ)
- if (this.visualHeight !== other.visualHeight ||
- this.contentWidth !== other.contentWidth ||
- this.lineCount !== other.lineCount) {
- return false;
- }
-
- // Use reference equality for tableData if same object
- if (this.tableData === other.tableData) return true;
-
- // Quick length checks
- const headers1 = this.tableData.headers;
- const headers2 = other.tableData.headers;
- const rows1 = this.tableData.rows;
- const rows2 = other.tableData.rows;
-
- if (headers1.length !== headers2.length || rows1.length !== rows2.length) {
- return false;
- }
-
- // Compare headers (usually short)
- for (let i = 0, len = headers1.length; i < len; i++) {
- if (headers1[i] !== headers2[i]) return false;
- }
-
- // Compare rows
- for (let i = 0, rowLen = rows1.length; i < rowLen; i++) {
- const row1 = rows1[i];
- const row2 = rows2[i];
- if (row1.length !== row2.length) return false;
- for (let j = 0, cellLen = row1.length; j < cellLen; j++) {
- if (row1[j] !== row2[j]) return false;
- }
- }
-
- return true;
- }
-
- ignoreEvent(): boolean {
- return false;
- }
+ ignoreEvent() { return false; }
}
-// ============================================================================
-// Decorations
-// ============================================================================
-
-/**
- * Check if a range overlaps with any folded region.
- */
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
const folded = foldedRanges(view.state);
const cursor = folded.iter();
while (cursor.value) {
- // Check if ranges overlap
- if (cursor.from < to && cursor.to > from) {
- return true;
- }
+ if (cursor.from < to && cursor.to > from) return true;
cursor.next();
}
return false;
}
-/** Result of building decorations - includes both decorations and table ranges */
-interface BuildResult {
- decorations: DecorationSet;
- tableRanges: TableRange[];
-}
-
/**
- * Build decorations for tables and collect table ranges in a single pass.
- * Optimized: single syntax tree traversal instead of two separate ones.
+ * Handle Table node.
*/
-function buildDecorationsAndRanges(view: EditorView): BuildResult {
- const decorations: Range[] = [];
- const tableRanges: TableRange[] = [];
- const contentWidth = view.contentDOM.clientWidth - 10;
- const lineHeight = view.defaultLineHeight;
+export function handleTable(
+ ctx: BuildContext,
+ nf: number,
+ nt: number,
+ node: SyntaxNode,
+ inCursor: boolean,
+ ranges: RangeTuple[]
+): void {
+ if (ctx.seen.has(nf)) return;
+ ctx.seen.add(nf);
+ ranges.push([nf, nt]);
+ if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
- // Pre-create the line decoration to reuse (same class for all hidden lines)
- const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' });
-
- for (const { from, to } of view.visibleRanges) {
- syntaxTree(view.state).iterate({
- from,
- to,
- enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
- if (type.name !== 'Table') return;
-
- // Always collect table ranges for selection tracking
- tableRanges.push({ from: nodeFrom, to: nodeTo });
-
- // Skip rendering if table is in a folded region
- if (isInFoldedRange(view, nodeFrom, nodeTo)) return;
-
- // Skip rendering if cursor/selection is in table range
- if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
-
- // Get cached or parse table data
- const tableData = getCachedTableData(view.state, node);
- if (!tableData) return;
-
- // Calculate line info
- const startLine = view.state.doc.lineAt(nodeFrom);
- const endLine = view.state.doc.lineAt(nodeTo);
- const lineCount = endLine.number - startLine.number + 1;
-
- // Get visual height using lineBlockAt (includes wrapped lines)
- const startBlock = view.lineBlockAt(nodeFrom);
- const endBlock = view.lineBlockAt(nodeTo);
- const visualHeight = endBlock.bottom - startBlock.top;
-
- // Add line decorations to hide content (reuse decoration object)
- for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
- const line = view.state.doc.line(lineNum);
- decorations.push(hiddenLineDecoration.range(line.from));
- }
-
- // Add widget on the first line (positioned absolutely)
- decorations.push(
- Decoration.widget({
- widget: new TableWidget(tableData, lineCount, lineHeight, visualHeight, contentWidth),
- side: -1
- }).range(startLine.from)
- );
- }
- });
+ const headerNode = node.getChild('TableHeader');
+ if (!headerNode) return;
+ const headers: string[] = [];
+ const alignments: CellAlign[] = [];
+ const rows: string[][] = [];
+ const headerCells = headerNode.getChildren('TableCell');
+ if (headerCells.length > 0) {
+ for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
+ } else {
+ headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
}
-
- return {
- decorations: Decoration.set(decorations, true),
- tableRanges
- };
-}
-
-// ============================================================================
-// Plugin
-// ============================================================================
-
-/**
- * Find which table the selection is in (if any).
- * Returns table index or -1 if not in any table.
- * Optimized: early exit on first match.
- */
-function findSelectionTableIndex(
- selectionRanges: readonly { from: number; to: number }[],
- tableRanges: TableRange[]
-): number {
- // Early exit if no tables
- if (tableRanges.length === 0) return -1;
-
- for (const sel of selectionRanges) {
- const selFrom = sel.from;
- const selTo = sel.to;
- for (let i = 0; i < tableRanges.length; i++) {
- const table = tableRanges[i];
- // Inline overlap check (avoid function call overhead)
- if (selFrom <= table.to && table.from <= selTo) {
- return i;
+ if (headers.length === 0) return;
+ let child = node.firstChild;
+ while (child) {
+ if (child.type.name === 'TableDelimiter') {
+ const delimText = ctx.view.state.sliceDoc(child.from, child.to);
+ if (delimText.includes('-')) {
+ for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
+ break;
}
}
+ child = child.nextSibling;
}
- return -1;
+ while (alignments.length < headers.length) alignments.push('left');
+ for (const rowNode of node.getChildren('TableRow')) {
+ const rowData: string[] = [];
+ const cells = rowNode.getChildren('TableCell');
+ if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
+ else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
+ while (rowData.length < headers.length) rowData.push('');
+ rows.push(rowData);
+ }
+ const startLine = ctx.view.state.doc.lineAt(nf);
+ const endLine = ctx.view.state.doc.lineAt(nt);
+ const lineCount = endLine.number - startLine.number + 1;
+ const startBlock = ctx.view.lineBlockAt(nf);
+ const endBlock = ctx.view.lineBlockAt(nt);
+ const visualHeight = endBlock.bottom - startBlock.top;
+ for (let num = startLine.number; num <= endLine.number; num++) {
+ ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
+ }
+ ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
}
/**
- * Table plugin with optimized update detection.
- *
- * Performance optimizations:
- * - Single syntax tree traversal (buildDecorationsAndRanges)
- * - Tracks table ranges to minimize unnecessary rebuilds
- * - Only rebuilds when selection enters/exits table OR switches between tables
- * - Detects both cursor position AND selection range changes
+ * Theme for tables.
*/
-class TablePlugin {
- decorations: DecorationSet;
- private tableRanges: TableRange[] = [];
- private lastContentWidth: number = 0;
- // Track last selection state for comparison
- private lastSelectionFrom: number = -1;
- private lastSelectionTo: number = -1;
- // Track which table the selection is in (-1 = not in any table)
- private lastTableIndex: number = -1;
-
- constructor(view: EditorView) {
- const result = buildDecorationsAndRanges(view);
- this.decorations = result.decorations;
- this.tableRanges = result.tableRanges;
- this.lastContentWidth = view.contentDOM.clientWidth;
- // Initialize selection tracking
- const mainSel = view.state.selection.main;
- this.lastSelectionFrom = mainSel.from;
- this.lastSelectionTo = mainSel.to;
- this.lastTableIndex = findSelectionTableIndex(view.state.selection.ranges, this.tableRanges);
- }
-
- update(update: ViewUpdate) {
- const view = update.view;
- const currentContentWidth = view.contentDOM.clientWidth;
-
- // Check if content width changed (requires rebuild for proper sizing)
- const widthChanged = Math.abs(currentContentWidth - this.lastContentWidth) > 1;
- if (widthChanged) {
- this.lastContentWidth = currentContentWidth;
- }
-
- // Full rebuild needed for:
- // - Document changes (table content may have changed)
- // - Viewport changes (new tables may be visible)
- // - Geometry changes (folding, line height changes)
- // - Width changes (table needs resizing)
- if (update.docChanged || update.viewportChanged || update.geometryChanged || widthChanged) {
- const result = buildDecorationsAndRanges(view);
- this.decorations = result.decorations;
- this.tableRanges = result.tableRanges;
- // Update selection tracking
- const mainSel = update.state.selection.main;
- this.lastSelectionFrom = mainSel.from;
- this.lastSelectionTo = mainSel.to;
- this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
- return;
- }
-
- // For selection changes, check if selection moved in/out of a table OR between tables
- if (update.selectionSet) {
- const mainSel = update.state.selection.main;
- const selectionChanged = mainSel.from !== this.lastSelectionFrom ||
- mainSel.to !== this.lastSelectionTo;
-
- if (selectionChanged) {
- // Find which table (if any) the selection is now in
- const currentTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
-
- // Rebuild if selection moved to a different table (including in/out)
- if (currentTableIndex !== this.lastTableIndex) {
- const result = buildDecorationsAndRanges(view);
- this.decorations = result.decorations;
- this.tableRanges = result.tableRanges;
- // Re-check after rebuild (table ranges may have changed)
- this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
- } else {
- this.lastTableIndex = currentTableIndex;
- }
-
- // Update tracking state
- this.lastSelectionFrom = mainSel.from;
- this.lastSelectionTo = mainSel.to;
- }
- }
- }
-}
-
-const tablePlugin = ViewPlugin.fromClass(
- TablePlugin,
- {
- decorations: (v) => v.decorations
- }
-);
-
-// ============================================================================
-// Theme
-// ============================================================================
-
-/**
- * Base theme for tables.
- */
-const baseTheme = EditorView.baseTheme({
- // Table container - same as math.ts
+export const tableTheme = EditorView.baseTheme({
'.cm-table-container': {
position: 'absolute',
display: 'flex',
@@ -691,19 +188,15 @@ const baseTheme = EditorView.baseTheme({
alignItems: 'flex-start',
pointerEvents: 'none',
zIndex: '2',
- overflow: 'hidden',
+ overflow: 'hidden'
},
-
- // Table wrapper - scrollable when needed
'.cm-table-wrapper': {
display: 'inline-block',
pointerEvents: 'auto',
backgroundColor: 'var(--bg-primary)',
overflowX: 'auto',
- overflowY: 'auto',
+ overflowY: 'auto'
},
-
- // Table styles - use inset box-shadow for outer border (not clipped by overflow)
'.cm-table': {
borderCollapse: 'separate',
borderSpacing: '0',
@@ -713,9 +206,8 @@ const baseTheme = EditorView.baseTheme({
backgroundColor: 'var(--cm-table-bg)',
border: 'none',
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
- color: 'var(--text-primary) !important',
+ color: 'var(--text-primary) !important'
},
-
'.cm-table th, .cm-table td': {
padding: '0 8px',
border: 'none',
@@ -725,109 +217,35 @@ const baseTheme = EditorView.baseTheme({
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
- // Prevent text wrapping to maintain row height
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
- maxWidth: '300px',
+ maxWidth: '300px'
},
-
- // Data cells: left divider + bottom divider
- '.cm-table td': {
- boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
- },
-
- // First column data cells: only bottom divider
- '.cm-table td:first-child': {
- boxShadow: '0 1px 0 var(--cm-table-border)',
- },
-
- // Last row data cells: only left divider (no bottom)
- '.cm-table tbody tr:last-child td': {
- boxShadow: '-1px 0 0 var(--cm-table-border)',
- },
-
- // Last row first column: no dividers
- '.cm-table tbody tr:last-child td:first-child': {
- boxShadow: 'none',
- },
-
+ '.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
+ '.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
+ '.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
+ '.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
'.cm-table th': {
backgroundColor: 'var(--cm-table-header-bg)',
fontWeight: '600',
- // Header cells: left divider + bottom divider
- boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
+ boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
},
-
- '.cm-table th:first-child': {
- // First header cell: only bottom divider
- boxShadow: '0 1px 0 var(--cm-table-border)',
- },
-
- '.cm-table tbody tr:hover': {
- backgroundColor: 'var(--cm-table-row-hover)',
- },
-
- // Alignment classes - use higher specificity to override default
- '.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': {
- textAlign: 'left',
- },
-
- '.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': {
- textAlign: 'center',
- },
-
- '.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': {
- textAlign: 'right',
- },
-
- // Inline elements in table cells
+ '.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
+ '.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
+ '.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
+ '.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { textAlign: 'center' },
+ '.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { textAlign: 'right' },
'.cm-table code': {
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
padding: '1px 4px',
borderRadius: '3px',
fontSize: 'inherit',
- fontFamily: 'var(--voidraft-font-mono)',
- },
-
- '.cm-table a': {
- color: 'var(--selection-text)',
- textDecoration: 'none',
- },
-
- '.cm-table a:hover': {
- textDecoration: 'underline',
- },
-
- // Hidden line content for table (text transparent but line preserved)
- // Use high specificity to override rainbow brackets and other plugins
- '.cm-line.cm-table-line-hidden': {
- color: 'transparent !important',
- caretColor: 'transparent',
- },
- '.cm-line.cm-table-line-hidden span': {
- color: 'transparent !important',
- },
- // Override rainbow brackets in hidden table lines
- '.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': {
- color: 'transparent !important',
+ fontFamily: 'var(--voidraft-font-mono)'
},
+ '.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
+ '.cm-table a:hover': { textDecoration: 'underline' },
+ '.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
+ '.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
+ '.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { color: 'transparent !important' }
});
-
-
-/**
- * Table extension.
- *
- * Features:
- * - Parses markdown tables using syntax tree
- * - Renders tables as beautiful HTML tables
- * - Table preserves line structure, overlays rendered table
- * - Shows source when cursor is inside
- */
-export const table = (): Extension => [
- tablePlugin,
- baseTheme
-];
-
-export default table;
-
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/theme.ts b/frontend/src/views/editor/extensions/markdown/plugins/theme.ts
new file mode 100644
index 0000000..68ea3bf
--- /dev/null
+++ b/frontend/src/views/editor/extensions/markdown/plugins/theme.ts
@@ -0,0 +1,33 @@
+/**
+ * Unified theme - combines all markdown plugin themes.
+ */
+
+import { Extension } from '@codemirror/state';
+import { blockquoteTheme } from './blockquote';
+import { codeBlockTheme } from './code-block';
+import { headingTheme } from './heading';
+import { horizontalRuleTheme } from './horizontal-rule';
+import { inlineStylesTheme } from './inline-styles';
+import { linkTheme } from './link';
+import { listTheme } from './list';
+import { footnoteTheme } from './footnote';
+import { mathTheme } from './math';
+import { emojiTheme } from './emoji';
+import { tableTheme } from './table';
+
+/**
+ * All markdown themes combined.
+ */
+export const Theme: Extension = [
+ blockquoteTheme,
+ codeBlockTheme,
+ headingTheme,
+ horizontalRuleTheme,
+ inlineStylesTheme,
+ linkTheme,
+ listTheme,
+ footnoteTheme,
+ mathTheme,
+ emojiTheme,
+ tableTheme
+];
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/types.ts b/frontend/src/views/editor/extensions/markdown/plugins/types.ts
new file mode 100644
index 0000000..71fa045
--- /dev/null
+++ b/frontend/src/views/editor/extensions/markdown/plugins/types.ts
@@ -0,0 +1,36 @@
+/**
+ * Shared types for unified markdown plugin handlers.
+ */
+
+import { Decoration, EditorView } from '@codemirror/view';
+import { RangeTuple } from '../util';
+import { SyntaxNode } from '@lezer/common';
+
+/** Decoration item to be added */
+export interface DecoItem {
+ from: number;
+ to: number;
+ deco: Decoration;
+ priority?: number;
+}
+
+/** Shared build context passed to all handlers */
+export interface BuildContext {
+ view: EditorView;
+ items: DecoItem[];
+ selRange: RangeTuple;
+ seen: Set;
+ processedLines: Set;
+ contentWidth: number;
+ lineHeight: number;
+}
+
+/** Handler function type */
+export type NodeHandler = (
+ ctx: BuildContext,
+ nf: number,
+ nt: number,
+ node: SyntaxNode,
+ inCursor: boolean,
+ ranges: RangeTuple[]
+) => void | boolean;
diff --git a/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts b/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
new file mode 100644
index 0000000..37d77f5
--- /dev/null
+++ b/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
@@ -0,0 +1,127 @@
+/**
+ * Emoji extension for Lezer Markdown parser.
+ *
+ * Parses :emoji_name: syntax for emoji shortcodes.
+ *
+ * Syntax: :emoji_name: → renders as actual emoji character
+ *
+ * Examples:
+ * - :smile: → 😄
+ * - :heart: → ❤️
+ * - :+1: → 👍
+ */
+
+import { MarkdownConfig, InlineContext } from '@lezer/markdown';
+import { CharCode } from '../util';
+import { emojies } from '@/common/constant/emojies';
+
+/**
+ * Pre-computed lookup table for emoji name characters.
+ * Valid characters: a-z, 0-9, _, +, -
+ * Uses Uint8Array for memory efficiency and O(1) lookup.
+ */
+const EMOJI_NAME_CHARS = new Uint8Array(128);
+// Initialize lookup table
+for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
+for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
+EMOJI_NAME_CHARS[95] = 1; // _
+EMOJI_NAME_CHARS[43] = 1; // +
+EMOJI_NAME_CHARS[45] = 1; // -
+
+/**
+ * O(1) check if a character is valid for emoji name.
+ * @param code - ASCII character code
+ * @returns True if valid emoji name character
+ */
+function isEmojiNameChar(code: number): boolean {
+ return code < 128 && EMOJI_NAME_CHARS[code] === 1;
+}
+
+/**
+ * Parse emoji :name: syntax.
+ *
+ * @param cx - Inline context
+ * @param pos - Start position (at :)
+ * @returns Position after element, or -1 if no match
+ */
+function parseEmoji(cx: InlineContext, pos: number): number {
+ const end = cx.end;
+
+ // Minimum: : + name + : = at least 3 chars, name must be non-empty
+ if (end < pos + 2) return -1;
+
+ // Track content for validation
+ let hasContent = false;
+ const contentStart = pos + 1;
+
+ // Search for closing :
+ for (let i = contentStart; i < end; i++) {
+ const char = cx.char(i);
+
+ // Found closing :
+ if (char === CharCode.Colon) {
+ // Must have content
+ if (!hasContent) return -1;
+
+ // Extract and validate emoji name
+ const name = cx.slice(contentStart, i).toLowerCase();
+
+ // Check if this is a valid emoji
+ if (!emojies[name]) return -1;
+
+ // Create element with marks and name
+ return cx.addElement(cx.elt('Emoji', pos, i + 1, [
+ cx.elt('EmojiMark', pos, contentStart),
+ cx.elt('EmojiName', contentStart, i),
+ cx.elt('EmojiMark', i, i + 1)
+ ]));
+ }
+
+ // Newline not allowed in emoji
+ if (char === CharCode.Newline) return -1;
+
+ // Space not allowed in emoji name
+ if (char === CharCode.Space || char === CharCode.Tab) return -1;
+
+ // Validate name character using O(1) lookup table
+ // Also check for uppercase A-Z (65-90) and convert mentally
+ const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
+ if (isEmojiNameChar(lowerChar)) {
+ hasContent = true;
+ } else {
+ return -1;
+ }
+ }
+
+ return -1;
+}
+
+/**
+ * Emoji extension for Lezer Markdown.
+ *
+ * Defines:
+ * - Emoji: The container node for emoji shortcode
+ * - EmojiMark: The : delimiter marks
+ * - EmojiName: The emoji name part
+ */
+export const Emoji: MarkdownConfig = {
+ defineNodes: [
+ { name: 'Emoji' },
+ { name: 'EmojiMark' },
+ { name: 'EmojiName' }
+ ],
+ parseInline: [
+ {
+ name: 'Emoji',
+ parse(cx, next, pos) {
+ // Fast path: must start with :
+ if (next !== CharCode.Colon) return -1;
+ return parseEmoji(cx, pos);
+ },
+ // Parse after emphasis to avoid conflicts with other syntax
+ after: 'Emphasis'
+ }
+ ]
+};
+
+export default Emoji;