feat: replace codemirror and image viewr, opt for simplier approach
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
|
||||
const NUNJUCKS_KEYWORDS = [
|
||||
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
|
||||
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
|
||||
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
|
||||
]
|
||||
|
||||
const NUNJUCKS_FILTERS = [
|
||||
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
|
||||
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
|
||||
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
|
||||
]
|
||||
|
||||
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
|
||||
function getLineText(state: EditorState, lineNo0Based: number): string {
|
||||
return state.doc.line(lineNo0Based + 1).text
|
||||
}
|
||||
|
||||
/** Detect if position is inside {{ or {% from the start of the line */
|
||||
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
const before = line.slice(0, posInLine)
|
||||
const openVar = before.lastIndexOf('{{')
|
||||
const openTag = before.lastIndexOf('{%')
|
||||
const closeVar = before.lastIndexOf('}}')
|
||||
const closeTag = before.lastIndexOf('%}')
|
||||
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
|
||||
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Get the word fragment before the cursor for matching */
|
||||
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
let start = posInLine
|
||||
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
|
||||
return line.slice(start, posInLine)
|
||||
}
|
||||
|
||||
export function nunjucksCompletionSource(
|
||||
variableIds: string[],
|
||||
configTitles?: string[],
|
||||
functionIds?: string[],
|
||||
dataIds?: string[],
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext) => {
|
||||
const { state, pos } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
|
||||
|
||||
const word = wordBefore(state, line.number - 1, pos - line.from)
|
||||
const from = pos - word.length
|
||||
|
||||
const options: { label: string; type?: string; info?: string }[] = []
|
||||
|
||||
for (const id of variableIds) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'variable', info: 'Variable' })
|
||||
}
|
||||
}
|
||||
for (const id of functionIds ?? []) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
|
||||
}
|
||||
}
|
||||
for (const kw of NUNJUCKS_KEYWORDS) {
|
||||
if (!word || kw.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
|
||||
}
|
||||
}
|
||||
for (const f of NUNJUCKS_FILTERS) {
|
||||
if (!word || f.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
|
||||
}
|
||||
}
|
||||
if (configTitles?.length) {
|
||||
for (const t of configTitles) {
|
||||
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: t, type: 'variable', info: 'Config' })
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dataId of dataIds ?? []) {
|
||||
if (!word || dataId.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: dataId, type: 'variable', info: 'Data (array of rows)' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
from,
|
||||
options: options.slice(0, 50),
|
||||
validFor: /^[\w.-]*$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
109
frontend/src/lib/nunjucksTokenizer.ts
Normal file
109
frontend/src/lib/nunjucksTokenizer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Nunjucks-aware tokenizer: splits content by {{ }}, {% %}, {# #} and highlights
|
||||
* code parts with Prism and nunjucks parts with fixed token types.
|
||||
* Used so the code editor always supports Nunjucks templating syntax.
|
||||
*/
|
||||
import Prism from 'prismjs'
|
||||
import type { Grammar } from 'prismjs'
|
||||
|
||||
export type Token = { types: string[]; content: string }
|
||||
|
||||
/** Single-line nunjucks patterns (variable {{ }}, tag {% %}, comment {# #}) */
|
||||
const NUNJUCKS_VAR = /\{\{[^}]*\}\}/g
|
||||
const NUNJUCKS_TAG = /\{\%[^%]*\%\}/g
|
||||
const NUNJUCKS_COMMENT = /\{\#[^#]*\#\}/g
|
||||
|
||||
/** Combined: match the first nunjucks block on a line (variable, tag, or comment) */
|
||||
const NUNJUCKS_PATTERN = /\{\{[^}]*\}\}|\{\%[^%]*\%\}|\{\#[^#]*\#\}/g
|
||||
|
||||
/** Nunjucks-specific token types so we can style them differently from the main language. */
|
||||
const TOKEN_VARIABLE = ['nunjucks-var']
|
||||
const TOKEN_TAG = ['nunjucks-tag']
|
||||
const TOKEN_COMMENT = ['nunjucks-comment']
|
||||
|
||||
function prismTokenToTypes(t: string | Prism.Token): string[] {
|
||||
if (typeof t === 'string') return ['plain']
|
||||
const type = t.type
|
||||
const types = Array.isArray(type) ? type : [type]
|
||||
const alias = (t as Prism.Token & { alias?: string | string[] }).alias
|
||||
if (alias) {
|
||||
const a = Array.isArray(alias) ? alias : [alias]
|
||||
return [...types, ...a]
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
function flattenPrismTokens(
|
||||
tokens: (string | Prism.Token)[],
|
||||
acc: Token[] = []
|
||||
): Token[] {
|
||||
for (const t of tokens) {
|
||||
if (typeof t === 'string') {
|
||||
acc.push({ types: ['plain'], content: t })
|
||||
} else {
|
||||
const types = prismTokenToTypes(t)
|
||||
const content = t.content
|
||||
if (typeof content === 'string') {
|
||||
acc.push({ types, content })
|
||||
} else {
|
||||
flattenPrismTokens(content as (string | Prism.Token)[], acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a line into segments: alternating code and nunjucks (variable/tag/comment).
|
||||
* Each nunjucks segment is one match; code segments are tokenized with Prism.
|
||||
*/
|
||||
function tokenizeLine(
|
||||
line: string,
|
||||
grammar: Grammar
|
||||
): Token[] {
|
||||
const result: Token[] = []
|
||||
let lastIndex = 0
|
||||
const re = new RegExp(NUNJUCKS_PATTERN.source, 'g')
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const codeSegment = line.slice(lastIndex, m.index)
|
||||
if (codeSegment.length > 0) {
|
||||
try {
|
||||
const prismTokens = Prism.tokenize(codeSegment, grammar)
|
||||
result.push(...flattenPrismTokens(prismTokens))
|
||||
} catch {
|
||||
result.push({ types: ['plain'], content: codeSegment })
|
||||
}
|
||||
}
|
||||
const nunjucksContent = m[0]
|
||||
if (nunjucksContent.startsWith('{{')) {
|
||||
result.push({ types: TOKEN_VARIABLE, content: nunjucksContent })
|
||||
} else if (nunjucksContent.startsWith('{%')) {
|
||||
result.push({ types: TOKEN_TAG, content: nunjucksContent })
|
||||
} else {
|
||||
result.push({ types: TOKEN_COMMENT, content: nunjucksContent })
|
||||
}
|
||||
lastIndex = re.lastIndex
|
||||
}
|
||||
const tail = line.slice(lastIndex)
|
||||
if (tail.length > 0) {
|
||||
try {
|
||||
const prismTokens = Prism.tokenize(tail, grammar)
|
||||
result.push(...flattenPrismTokens(prismTokens))
|
||||
} catch {
|
||||
result.push({ types: ['plain'], content: tail })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize code with Nunjucks support. Returns lines of tokens (same shape as prism-react-renderer).
|
||||
*/
|
||||
export function tokenizeWithNunjucks(
|
||||
code: string,
|
||||
grammar: Grammar
|
||||
): Token[][] {
|
||||
const lines = code.split('\n')
|
||||
return lines.map((line) => tokenizeLine(line, grammar))
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
/** Nunjucks block comment {# ... #} */
|
||||
function tokenNunjucksComment(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||
if (stream.match(/^\{#/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/#\}/)) return 'comment'
|
||||
stream.next()
|
||||
}
|
||||
return 'comment'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
||||
function tokenNunjucksBlock(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
|
||||
if (stream.match(/^\{\{/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/\}\}/)) return 'variableName.special'
|
||||
stream.next()
|
||||
}
|
||||
return 'variableName.special'
|
||||
}
|
||||
if (stream.match(/^\{\%/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/%\}/)) return 'keyword'
|
||||
stream.next()
|
||||
}
|
||||
return 'keyword'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
|
||||
const plantumlParser = StreamLanguage.define({
|
||||
name: 'plantuml',
|
||||
token(stream) {
|
||||
// Nunjucks {# ... #} comment
|
||||
const nunjucksComment = tokenNunjucksComment(stream)
|
||||
if (nunjucksComment) return nunjucksComment
|
||||
// Nunjucks {{ }} and {% %}
|
||||
const nunjucksBlock = tokenNunjucksBlock(stream)
|
||||
if (nunjucksBlock) return nunjucksBlock
|
||||
|
||||
// Single-quote line comment (PlantUML)
|
||||
if (stream.match(/^'/)) {
|
||||
stream.skipToEnd()
|
||||
return 'comment'
|
||||
}
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"/)) {
|
||||
let escaped = false
|
||||
while (!stream.eol()) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
stream.next()
|
||||
continue
|
||||
}
|
||||
const ch = stream.next()
|
||||
if (ch === '\\') escaped = true
|
||||
else if (ch === '"') break
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
// @directives (@startuml, @enduml, etc.)
|
||||
if (stream.match(/^@\w+/)) return 'meta'
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null
|
||||
// Arrows and connectors
|
||||
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
|
||||
// Keywords (participant, actor, as, title, etc.)
|
||||
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
|
||||
// Any other character (identifier, punctuation, etc.)
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const plantumlLanguage = plantumlParser
|
||||
7
frontend/src/lib/prismSetup.ts
Normal file
7
frontend/src/lib/prismSetup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Load Prism and register extra languages. Import this once before any syntax highlighting (e.g. in main.tsx).
|
||||
* setPrismGlobal must run first so component IIFEs see Prism on global.
|
||||
*/
|
||||
import './setPrismGlobal'
|
||||
import 'prismjs/components/prism-markdown'
|
||||
import 'prismjs/components/prism-plant-uml'
|
||||
4
frontend/src/lib/setPrismGlobal.ts
Normal file
4
frontend/src/lib/setPrismGlobal.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Run first so Prism is on global when language components load. */
|
||||
import Prism from 'prismjs'
|
||||
;(globalThis as Record<string, unknown>).Prism = Prism
|
||||
export {}
|
||||
55
frontend/src/lib/syntaxHighlight.tsx
Normal file
55
frontend/src/lib/syntaxHighlight.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Syntax highlighting for the code editor: Prism + prism-react-renderer + Nunjucks.
|
||||
* Nunjucks ({{ }}, {% %}, {# #}) is always applied so the editor supports templating everywhere.
|
||||
* Ensure prismSetup.ts is imported once in main.tsx, and prism theme: import 'prismjs/themes/prism.css'
|
||||
*/
|
||||
import React from 'react'
|
||||
import Prism from 'prismjs'
|
||||
import type { Grammar } from 'prismjs'
|
||||
import { tokenizeWithNunjucks } from '@/lib/nunjucksTokenizer'
|
||||
|
||||
export type HighlightLanguage = 'javascript' | 'markdown' | 'plantuml'
|
||||
|
||||
const prismLang: Record<HighlightLanguage, string> = {
|
||||
javascript: 'javascript',
|
||||
markdown: 'markdown',
|
||||
plantuml: 'plantuml',
|
||||
}
|
||||
|
||||
function getGrammar(language: HighlightLanguage): Grammar {
|
||||
const lang = prismLang[language]
|
||||
const g = (Prism.languages as Record<string, Grammar>)[lang]
|
||||
return g ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns highlighted code as React nodes with Nunjucks support.
|
||||
* Used by the shared CodeEditor so all usages get the same features (base language + Nunjucks).
|
||||
*/
|
||||
export function highlight(
|
||||
code: string,
|
||||
language: HighlightLanguage
|
||||
): React.ReactNode {
|
||||
const grammar = getGrammar(language)
|
||||
const lines = tokenizeWithNunjucks(code, grammar)
|
||||
return (
|
||||
<>
|
||||
{lines.map((lineTokens, i) => (
|
||||
<div key={i} className="token-line">
|
||||
{lineTokens.length > 0 ? (
|
||||
lineTokens.map((token, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={`token ${token.types.join(' ')}`}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="token">​</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user