mirror of
https://github.com/vadimmelnicuk/meo.git
synced 2026-05-03 12:40:38 +00:00
846 lines
24 KiB
TypeScript
846 lines
24 KiB
TypeScript
import { StateField, EditorState } from '@codemirror/state';
|
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
|
import { syntaxTree, StreamLanguage } from '@codemirror/language';
|
|
import { javascript } from '@codemirror/lang-javascript';
|
|
import { python } from '@codemirror/lang-python';
|
|
import { css } from '@codemirror/lang-css';
|
|
import { html } from '@codemirror/lang-html';
|
|
import { json } from '@codemirror/lang-json';
|
|
import { cpp } from '@codemirror/lang-cpp';
|
|
import { rust } from '@codemirror/lang-rust';
|
|
import { go } from '@codemirror/lang-go';
|
|
import { java } from '@codemirror/lang-java';
|
|
import { sql } from '@codemirror/lang-sql';
|
|
import { markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { MermaidDiagramWidget, getFencedCodeContent } from './mermaidDiagram';
|
|
import { getMermaidColonBlocks } from './mermaidColonBlocks';
|
|
|
|
const shellLanguage = StreamLanguage.define({
|
|
name: 'shell',
|
|
startState: () => ({}),
|
|
token: (stream: any) => {
|
|
if (stream.match(/^#.*/)) return 'comment';
|
|
if (stream.match(/^"[^$"]*"/)) return 'string';
|
|
if (stream.match(/^'[^']*'/)) return 'string';
|
|
if (stream.match(/^\$\{[^}]+\}/)) return 'variableName';
|
|
if (stream.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/)) return 'variableName';
|
|
if (stream.match(/^(if|then|else|elif|fi|for|do|done|while|case|esac|in|function|return|exit|echo|export|source|alias|unalias|cd|pwd|ls|grep|sed|awk|cat|printf|read|eval|local|declare|typeset|readonly|unset|shift|exec)\b/)) return 'keyword';
|
|
if (stream.match(/^(true|false)\b/)) return 'bool';
|
|
if (stream.match(/^\d+/)) return 'number';
|
|
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) return 'variableName';
|
|
if (stream.match(/^[^"'#\s$`{|]+/)) return 'operator';
|
|
stream.next();
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const powerQueryKeywords = /^(let|in|each|if|then|else|try|otherwise|error|and|or|not|as|is|type|meta|section|shared)\b/i;
|
|
const powerQueryHashKeywords = /^#(date|time|datetime|datetimezone|duration|table|binary|sections|shared)\b/i;
|
|
|
|
function consumePowerQueryQuotedTail(stream: any): void {
|
|
while (!stream.eol()) {
|
|
if (!stream.skipTo('"')) {
|
|
stream.skipToEnd();
|
|
break;
|
|
}
|
|
stream.next();
|
|
if (stream.peek() === '"') {
|
|
stream.next();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function consumePowerQueryQuoted(stream: any): boolean {
|
|
if (stream.next() !== '"') {
|
|
return false;
|
|
}
|
|
|
|
consumePowerQueryQuotedTail(stream);
|
|
return true;
|
|
}
|
|
|
|
interface PowerQueryState {
|
|
inBlockComment: boolean;
|
|
}
|
|
|
|
function consumePowerQueryBlockComment(stream: any, state: PowerQueryState): void {
|
|
state.inBlockComment = true;
|
|
while (!stream.eol()) {
|
|
if (stream.match('*/')) {
|
|
state.inBlockComment = false;
|
|
break;
|
|
}
|
|
stream.next();
|
|
}
|
|
}
|
|
|
|
const powerQueryLanguage = StreamLanguage.define({
|
|
name: 'powerquery',
|
|
startState: () => ({ inBlockComment: false }),
|
|
token: (stream: any, state: PowerQueryState) => {
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (state.inBlockComment) {
|
|
consumePowerQueryBlockComment(stream, state);
|
|
return 'comment';
|
|
}
|
|
|
|
if (stream.match('//')) {
|
|
stream.skipToEnd();
|
|
return 'comment';
|
|
}
|
|
|
|
if (stream.match('/*')) {
|
|
consumePowerQueryBlockComment(stream, state);
|
|
return 'comment';
|
|
}
|
|
|
|
if (stream.match(/^\[[^\]\r\n]+\]/)) {
|
|
return 'propertyName';
|
|
}
|
|
|
|
if (stream.match(/^@[a-z_][a-z0-9_]*/i)) {
|
|
return 'variableName';
|
|
}
|
|
|
|
if (stream.match(powerQueryHashKeywords)) {
|
|
return 'keyword';
|
|
}
|
|
|
|
if (stream.match(/^#"/)) {
|
|
consumePowerQueryQuotedTail(stream);
|
|
return 'string';
|
|
}
|
|
|
|
if (stream.peek() === '"') {
|
|
consumePowerQueryQuoted(stream);
|
|
return 'string';
|
|
}
|
|
|
|
if (stream.match(/^(true|false)\b/i)) {
|
|
return 'bool';
|
|
}
|
|
|
|
if (stream.match(/^null\b/i)) {
|
|
return 'atom';
|
|
}
|
|
|
|
if (stream.match(powerQueryKeywords)) {
|
|
return 'keyword';
|
|
}
|
|
|
|
if (stream.match(/^\d+(?:\.\d+)?(?:e[+-]?\d+)?/i)) {
|
|
return 'number';
|
|
}
|
|
|
|
if (stream.match(/^[a-z_][a-z0-9_.]*/i)) {
|
|
return 'variableName';
|
|
}
|
|
|
|
if (stream.match(/^(?:=>|<=|>=|<>|=|<|>|\+|-|\*|\/|&|\?|!|,|;|:|\(|\)|\{|\}|\[|\])+/)) {
|
|
return 'operator';
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const csharpKeywords = new Set([
|
|
"abstract", "as", "base", "break", "case", "catch", "checked", "class",
|
|
"const", "continue", "default", "delegate", "do", "else", "enum", "event",
|
|
"explicit", "extern", "finally", "fixed", "for", "foreach", "goto", "if",
|
|
"implicit", "in", "interface", "internal", "is", "lock", "namespace", "new",
|
|
"operator", "out", "override", "params", "private", "protected", "public",
|
|
"readonly", "ref", "return", "sealed", "sizeof", "stackalloc", "static",
|
|
"struct", "switch", "this", "throw", "try", "typeof", "unchecked", "unsafe",
|
|
"using", "virtual", "void", "volatile", "while",
|
|
"async", "await", "var", "dynamic", "yield", "when", "record", "init",
|
|
"required", "file", "scoped", "partial", "where", "select", "group", "into",
|
|
"let", "orderby", "join", "on", "equals", "by", "ascending", "descending",
|
|
"from", "global", "not", "and", "or", "with", "nameof"
|
|
]);
|
|
|
|
const csharpBuiltinTypes = new Set([
|
|
"bool", "byte", "char", "decimal", "double", "float", "int", "long",
|
|
"object", "sbyte", "short", "string", "uint", "ulong", "ushort",
|
|
"nint", "nuint"
|
|
]);
|
|
|
|
const csharpTypeKeywords = new Set([
|
|
"class", "struct", "interface", "enum", "record", "new", "as", "is",
|
|
"typeof", "sizeof", "nameof", "delegate", "event", "where"
|
|
]);
|
|
|
|
const csharpNamespaceKeywords = new Set(["namespace", "using"]);
|
|
|
|
type CSharpState = {
|
|
inBlockComment: boolean;
|
|
inVerbatimString: boolean;
|
|
inRawString: boolean;
|
|
rawQuoteCount: number;
|
|
expectTypeName: boolean;
|
|
expectNamespace: boolean;
|
|
afterDot: boolean;
|
|
inAttribute: boolean;
|
|
};
|
|
|
|
const csharpLanguage = StreamLanguage.define({
|
|
name: "csharp",
|
|
|
|
startState: (): CSharpState => ({
|
|
inBlockComment: false,
|
|
inVerbatimString: false,
|
|
inRawString: false,
|
|
rawQuoteCount: 0,
|
|
expectTypeName: false,
|
|
expectNamespace: false,
|
|
afterDot: false,
|
|
inAttribute: false
|
|
}),
|
|
|
|
token: (stream: any, state: CSharpState) => {
|
|
// Handle multiline block comment
|
|
if (state.inBlockComment) {
|
|
while (!stream.eol()) {
|
|
if (stream.match("*/")) {
|
|
state.inBlockComment = false;
|
|
break;
|
|
}
|
|
stream.next();
|
|
}
|
|
return "comment";
|
|
}
|
|
|
|
// Handle multiline verbatim string: @"..."
|
|
if (state.inVerbatimString) {
|
|
while (!stream.eol()) {
|
|
if (stream.match('""')) continue; // escaped quote in verbatim string
|
|
if (stream.match('"')) {
|
|
state.inVerbatimString = false;
|
|
break;
|
|
}
|
|
stream.next();
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
// Handle multiline raw string: """ ... """
|
|
if (state.inRawString) {
|
|
const end = '"'.repeat(state.rawQuoteCount);
|
|
while (!stream.eol()) {
|
|
if (stream.match(end)) {
|
|
state.inRawString = false;
|
|
break;
|
|
}
|
|
stream.next();
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) return null;
|
|
|
|
// Preprocessor directives
|
|
if (stream.sol() && stream.match(/^#\s*[A-Za-z_]\w*/)) {
|
|
stream.skipToEnd();
|
|
return "meta";
|
|
}
|
|
|
|
// Comments
|
|
if (stream.match("//")) {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
if (stream.match("/*")) {
|
|
state.inBlockComment = true;
|
|
return "comment";
|
|
}
|
|
|
|
// Raw strings: """ ... """ or more quotes
|
|
if (stream.match(/^"{3,}/)) {
|
|
state.inRawString = true;
|
|
state.rawQuoteCount = stream.current().length;
|
|
return "string";
|
|
}
|
|
|
|
// Verbatim interpolated strings: $@"..." or @$"..."
|
|
if (stream.match(/^\$@"/) || stream.match(/^@\$"/)) {
|
|
state.inVerbatimString = true;
|
|
return "string";
|
|
}
|
|
|
|
// Verbatim strings: @"..."
|
|
if (stream.match(/^@"/)) {
|
|
state.inVerbatimString = true;
|
|
return "string";
|
|
}
|
|
|
|
// Interpolated regular string
|
|
if (stream.match(/^\$"(?:[^"\\]|\\.)*"/)) return "string";
|
|
|
|
// Regular string
|
|
if (stream.match(/^"(?:[^"\\\r\n]|\\.)*"/)) return "string";
|
|
|
|
// Char literal: one char or one escape
|
|
if (stream.match(/^'(?:[^'\\\r\n]|\\.)'/)) return "string";
|
|
|
|
// Hex before decimal
|
|
if (stream.match(/^0[xX][0-9a-fA-F](?:[0-9a-fA-F_]*[0-9a-fA-F])?[uUlL]*/)) {
|
|
return "number";
|
|
}
|
|
|
|
// Binary
|
|
if (stream.match(/^0[bB][01](?:[01_]*[01])?[uUlL]*/)) {
|
|
return "number";
|
|
}
|
|
|
|
// Decimal / float
|
|
if (
|
|
stream.match(
|
|
/^(?:\d(?:[\d_]*\d)?)(?:\.(?:\d(?:[\d_]*\d)?)?)?(?:[eE][+-]?\d(?:[\d_]*\d)?)?[fFdDmM]?/
|
|
)
|
|
) {
|
|
return "number";
|
|
}
|
|
|
|
// Attribute brackets: [Serializable], [HttpGet("...")]
|
|
if (stream.match("[")) {
|
|
state.inAttribute = true;
|
|
return "squareBracket";
|
|
}
|
|
if (stream.match("]")) {
|
|
state.inAttribute = false;
|
|
return "squareBracket";
|
|
}
|
|
|
|
// Identifiers, including escaped identifiers like @class
|
|
if (stream.match(/^@?[A-Za-z_]\w*/)) {
|
|
const raw = stream.current();
|
|
const word = raw.startsWith("@") ? raw.slice(1) : raw;
|
|
const next = stream.peek();
|
|
const wasDot = state.afterDot;
|
|
state.afterDot = false;
|
|
|
|
if (csharpKeywords.has(word)) {
|
|
state.expectTypeName = csharpTypeKeywords.has(word);
|
|
state.expectNamespace = csharpNamespaceKeywords.has(word);
|
|
return "keyword";
|
|
}
|
|
if (csharpBuiltinTypes.has(word)) {
|
|
state.expectTypeName = false;
|
|
state.expectNamespace = false;
|
|
return "typeName";
|
|
}
|
|
if (word === "true" || word === "false") {
|
|
state.expectTypeName = false;
|
|
return "bool";
|
|
}
|
|
if (word === "null") {
|
|
state.expectTypeName = false;
|
|
return "atom";
|
|
}
|
|
|
|
// Namespace: using System.Collections.Generic
|
|
if (state.expectNamespace) {
|
|
return "namespace";
|
|
}
|
|
|
|
// Attribute name: [Serializable], [HttpGet]
|
|
if (state.inAttribute) {
|
|
return "attributeName";
|
|
}
|
|
|
|
// After a type-introducing keyword: new Foo, class Bar
|
|
if (state.expectTypeName) {
|
|
state.expectTypeName = false;
|
|
// new Foo() — type followed by '(' is still a type (constructor)
|
|
return "typeName";
|
|
}
|
|
|
|
const isUpperStart = word[0] >= "A" && word[0] <= "Z";
|
|
|
|
// Member access: obj.Method() or obj.Property
|
|
if (wasDot) {
|
|
if (next === "(") return "variableName.function";
|
|
if (next === "<") return "typeName";
|
|
return "propertyName";
|
|
}
|
|
|
|
// PascalCase followed by '<' — generic type: List<T>
|
|
if (isUpperStart && next === "<") {
|
|
return "typeName";
|
|
}
|
|
|
|
// Function call: Method(...)
|
|
if (next === "(") {
|
|
return "variableName.function";
|
|
}
|
|
|
|
state.expectTypeName = false;
|
|
return "variableName";
|
|
}
|
|
|
|
// Dot — member access
|
|
if (stream.match(".")) {
|
|
state.afterDot = true;
|
|
return "punctuation";
|
|
}
|
|
|
|
// Colon — expect type name after ':' (inheritance, type constraints)
|
|
if (stream.match(":")) {
|
|
state.expectTypeName = true;
|
|
return "punctuation";
|
|
}
|
|
|
|
// Angle brackets — '<' expects type arg, '>' does not
|
|
if (stream.match("<")) {
|
|
state.expectTypeName = true;
|
|
return "angleBracket";
|
|
}
|
|
if (stream.match(">")) {
|
|
return "angleBracket";
|
|
}
|
|
|
|
// Comma inside generic args or after base types keeps expecting types
|
|
if (stream.match(",")) {
|
|
return "punctuation";
|
|
}
|
|
|
|
// Parentheses and braces
|
|
if (stream.match(/^[()]/)) return "paren";
|
|
if (stream.match(/^[{}]/)) return "brace";
|
|
|
|
// Semicolon
|
|
if (stream.match(";")) {
|
|
state.expectNamespace = false;
|
|
return "punctuation";
|
|
}
|
|
|
|
// Lambda and other operators
|
|
if (stream.match("=>")) return "operator";
|
|
if (stream.match(/^[+\-*/%&|^!~?=<>]+/)) return "operator";
|
|
|
|
stream.next();
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const jsLanguage = javascript().language;
|
|
const jsxLanguage = javascript({ jsx: true }).language;
|
|
const tsLanguage = javascript({ typescript: true }).language;
|
|
const tsxLanguage = javascript({ typescript: true, jsx: true }).language;
|
|
const pythonLanguage = python().language;
|
|
const cssLanguage = css().language;
|
|
const htmlLanguage = html().language;
|
|
const jsonLanguage = json().language;
|
|
const swiftLanguage = cpp().language;
|
|
const rustLanguage = rust().language;
|
|
const goLanguage = go().language;
|
|
const javaLanguage = java().language;
|
|
const sqlLanguage = sql().language;
|
|
const markdownCodeLanguage = markdownLanguage;
|
|
|
|
const languageMap: Record<string, any> = {
|
|
javascript: jsLanguage,
|
|
js: jsLanguage,
|
|
jsx: jsxLanguage,
|
|
typescript: tsLanguage,
|
|
ts: tsLanguage,
|
|
tsx: tsxLanguage,
|
|
python: pythonLanguage,
|
|
py: pythonLanguage,
|
|
css: cssLanguage,
|
|
html: htmlLanguage,
|
|
htm: htmlLanguage,
|
|
json: jsonLanguage,
|
|
markdown: markdownCodeLanguage,
|
|
md: markdownCodeLanguage,
|
|
swift: swiftLanguage,
|
|
rust: rustLanguage,
|
|
rs: rustLanguage,
|
|
go: goLanguage,
|
|
golang: goLanguage,
|
|
java: javaLanguage,
|
|
sql: sqlLanguage,
|
|
csharp: csharpLanguage,
|
|
cs: csharpLanguage,
|
|
'c#': csharpLanguage,
|
|
shell: shellLanguage,
|
|
bash: shellLanguage,
|
|
sh: shellLanguage,
|
|
zsh: shellLanguage,
|
|
m: powerQueryLanguage,
|
|
powerquery: powerQueryLanguage,
|
|
pq: powerQueryLanguage
|
|
};
|
|
|
|
export function resolveCodeLanguage(info: string | null | undefined): any {
|
|
if (!info) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = info.toLowerCase().trim();
|
|
|
|
return languageMap[normalized] ?? null;
|
|
}
|
|
|
|
export function resolveLiveCodeLanguage(info: string | null | undefined): any {
|
|
if (!info) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = info.toLowerCase().trim();
|
|
if (normalized === 'markdown' || normalized === 'md') {
|
|
return null;
|
|
}
|
|
|
|
return languageMap[normalized] ?? null;
|
|
}
|
|
|
|
export function insertCodeBlock(view: EditorView, selection: { from: number; to: number; empty?: boolean }): void {
|
|
const { state } = view;
|
|
const line = state.doc.lineAt(selection.from);
|
|
const lineText = state.doc.sliceString(line.from, line.to);
|
|
const leadingWhitespace = /^(\s*)/.exec(lineText)![1];
|
|
|
|
if (!selection.empty) {
|
|
const selectedText = state.doc.sliceString(selection.from, selection.to);
|
|
const insert = `\n${leadingWhitespace}\`\`\`\n${selectedText}\n${leadingWhitespace}\`\`\`\n`;
|
|
view.dispatch({
|
|
changes: { from: selection.from, to: selection.to, insert },
|
|
selection: { anchor: selection.from + leadingWhitespace.length + 4 }
|
|
});
|
|
return;
|
|
}
|
|
|
|
const contentWithoutLeadingWhitespace = lineText.slice(leadingWhitespace.length);
|
|
const insert = `${leadingWhitespace}\`\`\`\n${contentWithoutLeadingWhitespace}\n${leadingWhitespace}\`\`\`\n`;
|
|
const cursorPos = line.from + leadingWhitespace.length + 4;
|
|
view.dispatch({
|
|
changes: { from: line.from, to: line.to, insert },
|
|
selection: { anchor: cursorPos }
|
|
});
|
|
}
|
|
|
|
const sourceCodeBlockLine = Decoration.line({ class: 'meo-src-code-block' });
|
|
const mermaidColonFenceMarker = Decoration.mark({ class: 'meo-md-colon-fence-marker' });
|
|
const mermaidColonFenceCode = Decoration.mark({ class: 'meo-md-colon-fence-code' });
|
|
|
|
function computeSourceCodeBlockLines(state: EditorState): any {
|
|
const ranges: any[] = [];
|
|
syntaxTree(state).iterate({
|
|
enter(node: any) {
|
|
if (node.name !== 'FencedCode' && node.name !== 'CodeBlock') {
|
|
return;
|
|
}
|
|
let line = state.doc.lineAt(node.from);
|
|
const end = state.doc.lineAt(Math.max(node.to - 1, node.from)).number;
|
|
while (line.number <= end) {
|
|
ranges.push(sourceCodeBlockLine.range(line.from));
|
|
if (line.number === end) {
|
|
break;
|
|
}
|
|
line = state.doc.line(line.number + 1);
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
|
|
for (const block of getMermaidColonBlocks(state)) {
|
|
for (let lineNo = block.startLine; lineNo <= block.endLine; lineNo += 1) {
|
|
const line = state.doc.line(lineNo);
|
|
ranges.push(sourceCodeBlockLine.range(line.from));
|
|
|
|
if (lineNo === block.startLine || lineNo === block.endLine) {
|
|
ranges.push(mermaidColonFenceMarker.range(line.from, line.to));
|
|
continue;
|
|
}
|
|
|
|
if (line.from < line.to) {
|
|
ranges.push(mermaidColonFenceCode.range(line.from, line.to));
|
|
}
|
|
}
|
|
}
|
|
|
|
return Decoration.set(ranges, true);
|
|
}
|
|
|
|
export const sourceCodeBlockField = StateField.define<any>({
|
|
create(state: EditorState) {
|
|
try {
|
|
return computeSourceCodeBlockLines(state);
|
|
} catch {
|
|
return Decoration.none;
|
|
}
|
|
},
|
|
update(lines: any, transaction: any) {
|
|
if (!transaction.docChanged) {
|
|
return lines;
|
|
}
|
|
try {
|
|
return computeSourceCodeBlockLines(transaction.state);
|
|
} catch {
|
|
return lines;
|
|
}
|
|
},
|
|
provide: (field: any) => EditorView.decorations.from(field)
|
|
});
|
|
|
|
export function isFenceMarker(state: EditorState, from: number, to: number): boolean {
|
|
const text = state.doc.sliceString(from, to);
|
|
return /^`{3,}$/.test(text) || /^~{3,}$/.test(text);
|
|
}
|
|
|
|
export function getFencedCodeInfo(state: EditorState, node: any): string | null {
|
|
let codeInfo: string | null = null;
|
|
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
if (child.name === 'CodeInfo') {
|
|
codeInfo = state.doc.sliceString(child.from, child.to).trim().toLowerCase();
|
|
break;
|
|
}
|
|
}
|
|
return codeInfo;
|
|
}
|
|
|
|
class CopyCodeButtonWidget extends WidgetType {
|
|
codeContent: string;
|
|
|
|
constructor(codeContent: string) {
|
|
super();
|
|
this.codeContent = codeContent;
|
|
}
|
|
|
|
eq(other: WidgetType): boolean {
|
|
return other instanceof CopyCodeButtonWidget && other.codeContent === this.codeContent;
|
|
}
|
|
|
|
toDOM(): HTMLElement {
|
|
const container = document.createElement('span');
|
|
container.className = 'meo-code-block-pill meo-copy-code-btn';
|
|
container.setAttribute('aria-label', 'Copy code');
|
|
container.setAttribute('role', 'button');
|
|
container.setAttribute('tabindex', '0');
|
|
container.textContent = 'copy';
|
|
|
|
const updateText = (copied: boolean) => {
|
|
container.textContent = copied ? 'copied' : 'copy';
|
|
container.classList.toggle('copied', copied);
|
|
};
|
|
|
|
const copy = async (e: Event) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await navigator.clipboard.writeText(this.codeContent);
|
|
updateText(true);
|
|
setTimeout(() => updateText(false), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
};
|
|
|
|
container.addEventListener('click', copy);
|
|
container.addEventListener('keydown', async (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
await copy(e);
|
|
}
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
ignoreEvent(event: Event): boolean {
|
|
return event.type !== 'pointerover' && event.type !== 'pointerout';
|
|
}
|
|
}
|
|
|
|
class CodeLanguageLabelWidget extends WidgetType {
|
|
labelText: string;
|
|
|
|
constructor(labelText: string) {
|
|
super();
|
|
this.labelText = labelText;
|
|
}
|
|
|
|
eq(other: WidgetType): boolean {
|
|
return other instanceof CodeLanguageLabelWidget && other.labelText === this.labelText;
|
|
}
|
|
|
|
toDOM(): HTMLElement {
|
|
const label = document.createElement('span');
|
|
label.className = 'meo-code-block-pill meo-code-language-label';
|
|
label.textContent = this.labelText;
|
|
label.setAttribute('aria-hidden', 'true');
|
|
return label;
|
|
}
|
|
|
|
ignoreEvent(): boolean {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function addTopLineWidget(builder: any[], lineEnd: number, widget: WidgetType): void {
|
|
builder.push(
|
|
Decoration.widget({
|
|
widget,
|
|
side: 1
|
|
}).range(lineEnd)
|
|
);
|
|
}
|
|
|
|
export function addTopLineCopyButton(builder: any[], lineEnd: number, codeContent: string): void {
|
|
if (!codeContent) {
|
|
return;
|
|
}
|
|
addTopLineWidget(builder, lineEnd, new CopyCodeButtonWidget(codeContent));
|
|
}
|
|
|
|
export function addTopLinePillLabel(builder: any[], lineEnd: number, labelText: string | null): void {
|
|
if (!labelText) {
|
|
return;
|
|
}
|
|
addTopLineWidget(builder, lineEnd, new CodeLanguageLabelWidget(labelText));
|
|
}
|
|
|
|
const quotedFenceOpeningLineRegex = /^[ \t]{0,3}(?:>[ \t]?)*[ \t]{0,3}(?:`{3,}|~{3,})/;
|
|
const fenceLineRegex = /^[ \t]*[`~]{3,}.*$/;
|
|
const quotedFencePrefixRegex = /^[ \t]{0,3}((?:>[ \t]?)*)[ \t]{0,3}(?:`{3,}|~{3,})/;
|
|
|
|
export function addFenceOpeningLineMarker(builder: any[], state: EditorState, from: number, activeLines: Set<number>, addRange: Function, activeLineMarkerDeco: any, fenceMarkerDeco: any): void {
|
|
const line = state.doc.lineAt(from);
|
|
const text = state.doc.sliceString(line.from, line.to);
|
|
// Support fenced code opening lines nested inside blockquotes/callouts, e.g. "> ```ts".
|
|
if (!quotedFenceOpeningLineRegex.test(text)) {
|
|
return;
|
|
}
|
|
|
|
if (activeLines.has(line.number)) {
|
|
addRange(builder, line.from, line.to, activeLineMarkerDeco);
|
|
return;
|
|
}
|
|
addRange(builder, line.from, line.to, fenceMarkerDeco);
|
|
}
|
|
|
|
export function addCodeLanguageLabel(builder: any[], state: EditorState, node: any, activeLines: Set<number>): void {
|
|
if (node.name !== 'FencedCode') {
|
|
return;
|
|
}
|
|
|
|
const startLine = state.doc.lineAt(node.from);
|
|
if (activeLines.has(startLine.number)) {
|
|
return;
|
|
}
|
|
|
|
const labelText = getFencedCodeInfo(state, node);
|
|
if (!labelText) {
|
|
return;
|
|
}
|
|
|
|
addTopLinePillLabel(builder, startLine.to, labelText);
|
|
}
|
|
|
|
export function addMermaidDiagram(builder: any[], state: EditorState, node: any): void {
|
|
const startLine = state.doc.lineAt(node.from);
|
|
const endLine = state.doc.lineAt(Math.max(node.to - 1, node.from));
|
|
const diagramText = getFencedCodeContent(state, node);
|
|
const fullBlockText = state.doc.sliceString(startLine.from, endLine.to);
|
|
|
|
addMermaidDiagramBlock(builder, state, {
|
|
startLine: startLine.number,
|
|
endLine: endLine.number,
|
|
diagramText,
|
|
fullBlockText
|
|
});
|
|
}
|
|
|
|
export function addMermaidDiagramBlock(
|
|
builder: any[],
|
|
state: EditorState,
|
|
block: {
|
|
startLine: number;
|
|
endLine: number;
|
|
diagramText: string;
|
|
fullBlockText: string;
|
|
}
|
|
): void {
|
|
if (!block.diagramText.trim()) {
|
|
return;
|
|
}
|
|
|
|
const startLine = state.doc.line(block.startLine);
|
|
const endLine = state.doc.line(block.endLine);
|
|
|
|
if (startLine.number >= endLine.number) {
|
|
return;
|
|
}
|
|
|
|
const contentStartLine = state.doc.line(startLine.number + 1);
|
|
const contentEndLine = state.doc.line(endLine.number - 1);
|
|
|
|
if (contentStartLine.from >= contentEndLine.to) {
|
|
return;
|
|
}
|
|
|
|
addTopLineCopyButton(builder, startLine.to, block.fullBlockText);
|
|
|
|
const widget = new MermaidDiagramWidget(block.diagramText, startLine.number, endLine.number);
|
|
builder.push(
|
|
Decoration.replace({
|
|
widget,
|
|
block: true
|
|
}).range(contentStartLine.from, contentEndLine.to)
|
|
);
|
|
}
|
|
|
|
export function addCopyCodeButton(builder: any[], state: EditorState, from: number, to: number): void {
|
|
const startLine = state.doc.lineAt(from);
|
|
const endLine = state.doc.lineAt(Math.max(to - 1, from));
|
|
const quoteDepth = getQuotedFenceDepth(startLine.text);
|
|
|
|
const codeLines: string[] = [];
|
|
for (let lineNum = startLine.number + 1; lineNum <= endLine.number; lineNum += 1) {
|
|
const line = state.doc.line(lineNum);
|
|
const lineText = stripLeadingQuotePrefix(line.text, quoteDepth);
|
|
|
|
if (lineNum === endLine.number) {
|
|
if (fenceLineRegex.test(lineText)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
codeLines.push(lineText);
|
|
}
|
|
|
|
const codeContent = codeLines.join('\n');
|
|
if (!codeContent) {
|
|
return;
|
|
}
|
|
|
|
addTopLineCopyButton(builder, startLine.to, codeContent);
|
|
}
|
|
|
|
function getQuotedFenceDepth(lineText: string): number {
|
|
const match = quotedFencePrefixRegex.exec(lineText);
|
|
if (!match) {
|
|
return 0;
|
|
}
|
|
return (match[1].match(/>/g) ?? []).length;
|
|
}
|
|
|
|
function stripLeadingQuotePrefix(lineText: string, quoteDepth: number): string {
|
|
if (quoteDepth <= 0) {
|
|
return lineText;
|
|
}
|
|
|
|
const prefixRe = new RegExp(`^[ \\t]{0,3}(?:>[ \\t]?){${quoteDepth}}`);
|
|
const match = prefixRe.exec(lineText);
|
|
if (!match) {
|
|
return lineText;
|
|
}
|
|
return lineText.slice(match[0].length);
|
|
}
|