Refactor CodeMirror Language Support and Enhance Template Handling
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Updated fcTemplateLang to utilize new helper functions for improved template type handling. - Introduced typedScope for better autocompletion support in JavaScript language integration. - Enhanced CodeBlockEditor and TemplateEditor components to support cursor change events and template type management. - Added fetchTemplateIntellisense function in ApiServerContext for improved intellisense capabilities based on cursor position. - Refactored related components to ensure consistent handling of template types and cursor changes, enhancing user experience.
This commit is contained in:
parent
a9e4c8dad3
commit
a590650d80
@ -9,7 +9,7 @@ import {
|
|||||||
import { styleTags, tags as t } from '@lezer/highlight'
|
import { styleTags, tags as t } from '@lezer/highlight'
|
||||||
import { parseMixed } from '@lezer/common'
|
import { parseMixed } from '@lezer/common'
|
||||||
import { javascriptLanguage } from '@codemirror/lang-javascript'
|
import { javascriptLanguage } from '@codemirror/lang-javascript'
|
||||||
import { javascriptCompletionSupport } from '../javascriptLang'
|
import { javascriptCompletionSupport } from '../javascriptLang/index.js'
|
||||||
import {
|
import {
|
||||||
xmlLanguage,
|
xmlLanguage,
|
||||||
completeFromSchema,
|
completeFromSchema,
|
||||||
@ -19,8 +19,9 @@ import { parser as ejsParser } from './ejs.parser.js'
|
|||||||
import {
|
import {
|
||||||
fcTemplateElements,
|
fcTemplateElements,
|
||||||
fcTemplateAttributes,
|
fcTemplateAttributes,
|
||||||
fcTemplateHelpers
|
getFcTemplateHelpers
|
||||||
} from './schema.js'
|
} from './schema.js'
|
||||||
|
import { normalizeTypedScope } from '../javascriptLang/typedScope.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FarmControl document template language: custom XML tags with EJS
|
* FarmControl document template language: custom XML tags with EJS
|
||||||
@ -71,31 +72,22 @@ export const fcTemplateLanguage = LRLanguage.define({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildJsScope(autoCompleteObject) {
|
function buildJsScope(autoCompleteObject, templateType) {
|
||||||
let data = autoCompleteObject
|
const typed = normalizeTypedScope(autoCompleteObject)
|
||||||
if (typeof data === 'string') {
|
typed.properties = {
|
||||||
try {
|
...typed.properties,
|
||||||
data = JSON.parse(data)
|
fc: getFcTemplateHelpers(templateType)
|
||||||
} catch {
|
|
||||||
data = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
||||||
data = {}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
fc: fcTemplateHelpers
|
|
||||||
}
|
}
|
||||||
|
return typed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Language support for FarmControl EJS+XML document templates.
|
* Language support for FarmControl EJS+XML document templates.
|
||||||
* @param {{ autoCompleteObject?: object|string }} [options]
|
* @param {{ autoCompleteObject?: object|string, templateType?: string }} [options]
|
||||||
*/
|
*/
|
||||||
export function fcTemplateLang(options = {}) {
|
export function fcTemplateLang(options = {}) {
|
||||||
const { autoCompleteObject } = options
|
const { autoCompleteObject, templateType = 'documentTemplate' } = options
|
||||||
const jsScope = buildJsScope(autoCompleteObject)
|
const jsScope = buildJsScope(autoCompleteObject, templateType)
|
||||||
|
|
||||||
return new LanguageSupport(fcTemplateLanguage, [
|
return new LanguageSupport(fcTemplateLanguage, [
|
||||||
xmlLanguage.data.of({
|
xmlLanguage.data.of({
|
||||||
|
|||||||
@ -102,9 +102,49 @@ export const fcTemplateElements = allTags.map((name) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Runtime helpers available as `fc` inside templates. */
|
/** Runtime helpers available as `fc` inside templates. */
|
||||||
export const fcTemplateHelpers = {
|
const fcSharedHelpers = {
|
||||||
listObjects: async () => [],
|
listObjects: {
|
||||||
getObject: async () => ({}),
|
$type: 'function',
|
||||||
formatDate: () => '',
|
detail: '(objectType, filter?, populate?) => Promise<object[]>'
|
||||||
renderDocumentTemplate: async () => ''
|
},
|
||||||
|
getObject: {
|
||||||
|
$type: 'function',
|
||||||
|
detail: '(objectType, id, populate?) => Promise<object>'
|
||||||
|
},
|
||||||
|
formatDate: {
|
||||||
|
$type: 'function',
|
||||||
|
detail: '(date, format) => string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fcDocumentTemplateHelpers = {
|
||||||
|
$type: 'object',
|
||||||
|
name: 'fc',
|
||||||
|
properties: {
|
||||||
|
...fcSharedHelpers,
|
||||||
|
renderDocumentTemplate: {
|
||||||
|
$type: 'function',
|
||||||
|
detail: '(reference, object?) => Promise<string>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fcEmailTemplateHelpers = {
|
||||||
|
$type: 'object',
|
||||||
|
name: 'fc',
|
||||||
|
properties: {
|
||||||
|
...fcSharedHelpers,
|
||||||
|
renderEmailTemplate: {
|
||||||
|
$type: 'function',
|
||||||
|
detail: '(reference, object?) => Promise<string>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fcTemplateHelpers = fcDocumentTemplateHelpers
|
||||||
|
|
||||||
|
export function getFcTemplateHelpers(templateType) {
|
||||||
|
return templateType === 'emailTemplate'
|
||||||
|
? fcEmailTemplateHelpers
|
||||||
|
: fcDocumentTemplateHelpers
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,27 +4,10 @@ import {
|
|||||||
scopeCompletionSource
|
scopeCompletionSource
|
||||||
} from '@codemirror/lang-javascript'
|
} from '@codemirror/lang-javascript'
|
||||||
import { nodeJsScope } from './nodeScope.js'
|
import { nodeJsScope } from './nodeScope.js'
|
||||||
|
import { typedScopeCompletionSource } from './typedScope.js'
|
||||||
|
|
||||||
function extraScope(autoCompleteObject) {
|
export function nodeScopeCompletionSource() {
|
||||||
let data = autoCompleteObject
|
return scopeCompletionSource(nodeJsScope)
|
||||||
if (typeof data === 'string') {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(data)
|
|
||||||
} catch {
|
|
||||||
data = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nodeScopeCompletionSource(autoCompleteObject) {
|
|
||||||
return scopeCompletionSource({
|
|
||||||
...nodeJsScope,
|
|
||||||
...extraScope(autoCompleteObject)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,7 +23,10 @@ export function javascriptCompletionSupport(autoCompleteObject) {
|
|||||||
return [
|
return [
|
||||||
javascript().support,
|
javascript().support,
|
||||||
javascriptLanguage.data.of({
|
javascriptLanguage.data.of({
|
||||||
autocomplete: nodeScopeCompletionSource(autoCompleteObject)
|
autocomplete: scopeCompletionSource(nodeJsScope)
|
||||||
|
}),
|
||||||
|
javascriptLanguage.data.of({
|
||||||
|
autocomplete: typedScopeCompletionSource(autoCompleteObject)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -55,7 +41,10 @@ export function javascriptLang(options = {}) {
|
|||||||
return [
|
return [
|
||||||
javascript(),
|
javascript(),
|
||||||
javascriptLanguage.data.of({
|
javascriptLanguage.data.of({
|
||||||
autocomplete: nodeScopeCompletionSource(autoCompleteObject)
|
autocomplete: scopeCompletionSource(nodeJsScope)
|
||||||
|
}),
|
||||||
|
javascriptLanguage.data.of({
|
||||||
|
autocomplete: typedScopeCompletionSource(autoCompleteObject)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
319
src/codemirror/javascriptLang/typedScope.js
Normal file
319
src/codemirror/javascriptLang/typedScope.js
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import { completionPath } from '@codemirror/lang-javascript'
|
||||||
|
|
||||||
|
const IDENTIFIER = /^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/
|
||||||
|
const ISO_DATE_RE =
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/
|
||||||
|
const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/
|
||||||
|
const MAX_STRING_LENGTH = 80
|
||||||
|
|
||||||
|
function fn(detail) {
|
||||||
|
return { $type: 'function', detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING_MEMBERS = {
|
||||||
|
length: { $type: 'number' },
|
||||||
|
charAt: fn('(index) => string'),
|
||||||
|
includes: fn('(search) => boolean'),
|
||||||
|
indexOf: fn('(search) => number'),
|
||||||
|
padStart: fn('(length, fill?) => string'),
|
||||||
|
replace: fn('(pattern, replacement) => string'),
|
||||||
|
slice: fn('(start, end?) => string'),
|
||||||
|
split: fn('(separator) => string[]'),
|
||||||
|
startsWith: fn('(search) => boolean'),
|
||||||
|
endsWith: fn('(search) => boolean'),
|
||||||
|
substring: fn('(start, end?) => string'),
|
||||||
|
toLowerCase: fn('() => string'),
|
||||||
|
toUpperCase: fn('() => string'),
|
||||||
|
trim: fn('() => string')
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMBER_MEMBERS = {
|
||||||
|
toFixed: fn('(digits?) => string'),
|
||||||
|
toString: fn('() => string'),
|
||||||
|
valueOf: fn('() => number')
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_MEMBERS = {
|
||||||
|
getDate: fn('() => number'),
|
||||||
|
getDay: fn('() => number'),
|
||||||
|
getFullYear: fn('() => number'),
|
||||||
|
getHours: fn('() => number'),
|
||||||
|
getMinutes: fn('() => number'),
|
||||||
|
getMonth: fn('() => number'),
|
||||||
|
getSeconds: fn('() => number'),
|
||||||
|
getTime: fn('() => number'),
|
||||||
|
toISOString: fn('() => string'),
|
||||||
|
toLocaleDateString: fn('() => string'),
|
||||||
|
toLocaleString: fn('() => string'),
|
||||||
|
toString: fn('() => string'),
|
||||||
|
valueOf: fn('() => number')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARRAY_MEMBERS = {
|
||||||
|
length: { $type: 'number' },
|
||||||
|
at: fn('(index) => any'),
|
||||||
|
concat: fn('(...items) => any[]'),
|
||||||
|
every: fn('(predicate) => boolean'),
|
||||||
|
filter: fn('(predicate) => any[]'),
|
||||||
|
find: fn('(predicate) => any'),
|
||||||
|
forEach: fn('(callback) => void'),
|
||||||
|
includes: fn('(value) => boolean'),
|
||||||
|
join: fn('(separator?) => string'),
|
||||||
|
map: fn('(callback) => any[]'),
|
||||||
|
reduce: fn('(callback, initial?) => any'),
|
||||||
|
slice: fn('(start, end?) => any[]'),
|
||||||
|
some: fn('(predicate) => boolean')
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOLEAN_MEMBERS = {
|
||||||
|
toString: fn('() => string'),
|
||||||
|
valueOf: fn('() => boolean')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTypedNode(value) {
|
||||||
|
return (
|
||||||
|
value != null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
typeof value.$type === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferStringType(value, key) {
|
||||||
|
const truncated =
|
||||||
|
value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) : value
|
||||||
|
if (ISO_DATE_RE.test(value)) {
|
||||||
|
return { $type: 'Date', value: truncated }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(key === '_id' || key === 'id' || /Id$/.test(String(key || ''))) &&
|
||||||
|
OBJECT_ID_RE.test(value)
|
||||||
|
) {
|
||||||
|
return { $type: 'ObjectId', value: truncated }
|
||||||
|
}
|
||||||
|
return { $type: 'string', value: truncated }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferTypedNode(value, key = null, depth = 0, seen = new WeakSet()) {
|
||||||
|
if (isTypedNode(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return { $type: 'null' }
|
||||||
|
}
|
||||||
|
const valueType = typeof value
|
||||||
|
if (valueType === 'string') {
|
||||||
|
return inferStringType(value, key)
|
||||||
|
}
|
||||||
|
if (valueType === 'number') {
|
||||||
|
return { $type: 'number', value }
|
||||||
|
}
|
||||||
|
if (valueType === 'boolean') {
|
||||||
|
return { $type: 'boolean', value }
|
||||||
|
}
|
||||||
|
if (valueType === 'function') {
|
||||||
|
return { $type: 'function', detail: 'fn' }
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return { $type: 'Date', value: value.toISOString() }
|
||||||
|
}
|
||||||
|
if (valueType !== 'object') {
|
||||||
|
return { $type: 'any' }
|
||||||
|
}
|
||||||
|
if (seen.has(value) || depth > 6) {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? { $type: 'array', element: { $type: 'any' } }
|
||||||
|
: { $type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
seen.add(value)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
let element = null
|
||||||
|
for (const item of value.slice(0, 3)) {
|
||||||
|
const typed = inferTypedNode(item, null, depth + 1, seen)
|
||||||
|
if (typed && typed.$type !== 'null') {
|
||||||
|
element = typed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { $type: 'array', element: element || { $type: 'any' } }
|
||||||
|
}
|
||||||
|
const properties = {}
|
||||||
|
for (const [nestedKey, nested] of Object.entries(value)) {
|
||||||
|
const typed = inferTypedNode(nested, nestedKey, depth + 1, seen)
|
||||||
|
if (typed) {
|
||||||
|
properties[nestedKey] = typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { $type: 'object', properties }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTypedScope(autoCompleteObject) {
|
||||||
|
let data = autoCompleteObject
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
data = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTypedNode(data) && data.$type === 'object') {
|
||||||
|
return {
|
||||||
|
$type: 'object',
|
||||||
|
...(data.name ? { name: data.name } : {}),
|
||||||
|
properties: { ...(data.properties || {}) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data != null && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
|
const values = Object.values(data)
|
||||||
|
if (
|
||||||
|
values.length > 0 &&
|
||||||
|
values.every((value) => isTypedNode(value))
|
||||||
|
) {
|
||||||
|
return { $type: 'object', properties: { ...data } }
|
||||||
|
}
|
||||||
|
return inferTypedNode(data)
|
||||||
|
}
|
||||||
|
return { $type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeLabel(node) {
|
||||||
|
if (!isTypedNode(node)) {
|
||||||
|
return 'any'
|
||||||
|
}
|
||||||
|
switch (node.$type) {
|
||||||
|
case 'array':
|
||||||
|
return `${typeLabel(node.element)}[]`
|
||||||
|
case 'object':
|
||||||
|
return node.name || 'object'
|
||||||
|
case 'ObjectId':
|
||||||
|
return node.name ? `ObjectId<${node.name}>` : 'ObjectId'
|
||||||
|
case 'function':
|
||||||
|
return node.detail || 'function'
|
||||||
|
case 'null':
|
||||||
|
return 'null'
|
||||||
|
default:
|
||||||
|
return node.$type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtinMembers(node) {
|
||||||
|
switch (node?.$type) {
|
||||||
|
case 'string':
|
||||||
|
case 'ObjectId':
|
||||||
|
return STRING_MEMBERS
|
||||||
|
case 'number':
|
||||||
|
return NUMBER_MEMBERS
|
||||||
|
case 'Date':
|
||||||
|
return DATE_MEMBERS
|
||||||
|
case 'array':
|
||||||
|
return ARRAY_MEMBERS
|
||||||
|
case 'boolean':
|
||||||
|
return BOOLEAN_MEMBERS
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTypedPath(root, path) {
|
||||||
|
let node = root
|
||||||
|
for (const step of path) {
|
||||||
|
if (!isTypedNode(node)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (node.$type === 'object') {
|
||||||
|
node = node.properties?.[step]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.$type === 'array' && step === 'length') {
|
||||||
|
node = { $type: 'number' }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const members = builtinMembers(node)
|
||||||
|
if (members?.[step]) {
|
||||||
|
node = members[step]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return isTypedNode(node) ? node : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionType(node, topLevel) {
|
||||||
|
if (node?.$type === 'function') {
|
||||||
|
return topLevel ? 'function' : 'method'
|
||||||
|
}
|
||||||
|
return topLevel ? 'variable' : 'property'
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumerateTypedCompletions(node, topLevel) {
|
||||||
|
if (!isTypedNode(node)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const options = []
|
||||||
|
const seen = new Set()
|
||||||
|
if (node.$type === 'object') {
|
||||||
|
for (const [name, child] of Object.entries(node.properties || {})) {
|
||||||
|
if (!IDENTIFIER.test(name) || seen.has(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(name)
|
||||||
|
options.push({
|
||||||
|
label: name,
|
||||||
|
type: completionType(child, topLevel),
|
||||||
|
detail: typeLabel(child),
|
||||||
|
boost: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const members = builtinMembers(node)
|
||||||
|
if (members) {
|
||||||
|
for (const [name, child] of Object.entries(members)) {
|
||||||
|
if (!IDENTIFIER.test(name) || seen.has(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(name)
|
||||||
|
options.push({
|
||||||
|
label: name,
|
||||||
|
type: completionType(child, false),
|
||||||
|
detail: typeLabel(child),
|
||||||
|
boost: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes template locals from a typed intellisense tree, showing
|
||||||
|
* JS/mongoose types in the dropdown detail column.
|
||||||
|
*/
|
||||||
|
export function typedScopeCompletionSource(autoCompleteObject) {
|
||||||
|
const scope = normalizeTypedScope(autoCompleteObject)
|
||||||
|
const cache = new Map()
|
||||||
|
return (context) => {
|
||||||
|
const path = completionPath(context)
|
||||||
|
if (!path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const target = path.path.length === 0 ? scope : resolveTypedPath(scope, path.path)
|
||||||
|
if (!target) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let options = cache.get(target)
|
||||||
|
if (!options) {
|
||||||
|
options = enumerateTypedCompletions(target, path.path.length === 0)
|
||||||
|
cache.set(target, options)
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: context.pos - path.name.length,
|
||||||
|
options,
|
||||||
|
validFor: IDENTIFIER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { EditorView, Decoration, ViewPlugin } from '@codemirror/view'
|
import { EditorView, Decoration, ViewPlugin } from '@codemirror/view'
|
||||||
@ -105,6 +105,37 @@ function buildErrorLineDecorations(state, errorLineSet) {
|
|||||||
return Decoration.set(decorations, true)
|
return Decoration.set(decorations, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCursorChangeExtension(onCursorChangeRef) {
|
||||||
|
return ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
constructor(view) {
|
||||||
|
this.report(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update) {
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
this.report(update.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report(view) {
|
||||||
|
const onCursorChange = onCursorChangeRef.current
|
||||||
|
if (typeof onCursorChange !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const head = view.state.selection.main.head
|
||||||
|
const line = view.state.doc.lineAt(head)
|
||||||
|
onCursorChange({
|
||||||
|
offset: head,
|
||||||
|
line: line.number,
|
||||||
|
column: head - line.from,
|
||||||
|
content: view.state.doc.toString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function createErrorLineExtension(errorLines) {
|
function createErrorLineExtension(errorLines) {
|
||||||
const errorLineSet = new Set(errorLines)
|
const errorLineSet = new Set(errorLines)
|
||||||
|
|
||||||
@ -135,16 +166,23 @@ export default function CodeBlockEditor({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
autoCompleteObject = null,
|
autoCompleteObject = null,
|
||||||
errorLines = []
|
errorLines = [],
|
||||||
|
templateType,
|
||||||
|
onCursorChange = null
|
||||||
}) {
|
}) {
|
||||||
const { isDarkMode } = useThemeContext()
|
const { isDarkMode } = useThemeContext()
|
||||||
const [codeMirrorOpen, setCodeMirrorOpen] = useState(false)
|
const [codeMirrorOpen, setCodeMirrorOpen] = useState(false)
|
||||||
const sourceCode = value !== undefined ? value : code
|
const sourceCode = value !== undefined ? value : code
|
||||||
const editorCode = toEditorCode(sourceCode, language)
|
const editorCode = toEditorCode(sourceCode, language)
|
||||||
|
const onCursorChangeRef = useRef(onCursorChange)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCursorChangeRef.current = onCursorChange
|
||||||
|
}, [onCursorChange])
|
||||||
|
|
||||||
const languageExtension = useMemo(
|
const languageExtension = useMemo(
|
||||||
() => getCodeLanguageExtension(language, autoCompleteObject),
|
() => getCodeLanguageExtension(language, autoCompleteObject, templateType),
|
||||||
[language, autoCompleteObject]
|
[language, autoCompleteObject, templateType]
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorLineExtension = useMemo(() => {
|
const errorLineExtension = useMemo(() => {
|
||||||
@ -154,14 +192,20 @@ export default function CodeBlockEditor({
|
|||||||
return [createErrorLineExtension(errorLines)]
|
return [createErrorLineExtension(errorLines)]
|
||||||
}, [errorLines])
|
}, [errorLines])
|
||||||
|
|
||||||
|
const cursorChangeExtension = useMemo(
|
||||||
|
() => createCursorChangeExtension(onCursorChangeRef),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const editorExtensions = useMemo(
|
const editorExtensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
languageExtension,
|
languageExtension,
|
||||||
...errorLineExtension,
|
...errorLineExtension,
|
||||||
|
cursorChangeExtension,
|
||||||
codeBlockEditorLayoutTheme,
|
codeBlockEditorLayoutTheme,
|
||||||
createScrollBoxSyncExtension()
|
createScrollBoxSyncExtension()
|
||||||
],
|
],
|
||||||
[languageExtension, errorLineExtension]
|
[languageExtension, errorLineExtension, cursorChangeExtension]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleOnChange = (value) => {
|
const handleOnChange = (value) => {
|
||||||
@ -282,5 +326,7 @@ CodeBlockEditor.propTypes = {
|
|||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
minimal: PropTypes.bool,
|
minimal: PropTypes.bool,
|
||||||
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
||||||
errorLines: PropTypes.arrayOf(PropTypes.number)
|
errorLines: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
templateType: PropTypes.string,
|
||||||
|
onCursorChange: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,6 +117,8 @@ const ObjectProperty = ({
|
|||||||
minimal = false,
|
minimal = false,
|
||||||
autoCompleteObject = null,
|
autoCompleteObject = null,
|
||||||
errorLines = [],
|
errorLines = [],
|
||||||
|
templateType,
|
||||||
|
onCursorChange,
|
||||||
previewOpen = false,
|
previewOpen = false,
|
||||||
showPreview = true,
|
showPreview = true,
|
||||||
useFormItem = true,
|
useFormItem = true,
|
||||||
@ -436,6 +438,8 @@ const ObjectProperty = ({
|
|||||||
minimal={minimal}
|
minimal={minimal}
|
||||||
autoCompleteObject={autoCompleteObject}
|
autoCompleteObject={autoCompleteObject}
|
||||||
errorLines={errorLines}
|
errorLines={errorLines}
|
||||||
|
templateType={templateType}
|
||||||
|
onCursorChange={onCursorChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -987,6 +991,8 @@ const ObjectProperty = ({
|
|||||||
minimal={minimal}
|
minimal={minimal}
|
||||||
autoCompleteObject={autoCompleteObject}
|
autoCompleteObject={autoCompleteObject}
|
||||||
errorLines={errorLines}
|
errorLines={errorLines}
|
||||||
|
templateType={templateType}
|
||||||
|
onCursorChange={onCursorChange}
|
||||||
{...(onChange && !useFormItem ? { onChange } : {})}
|
{...(onChange && !useFormItem ? { onChange } : {})}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
@ -1140,6 +1146,8 @@ ObjectProperty.propTypes = {
|
|||||||
language: PropTypes.string,
|
language: PropTypes.string,
|
||||||
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
||||||
errorLines: PropTypes.arrayOf(PropTypes.number),
|
errorLines: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
templateType: PropTypes.string,
|
||||||
|
onCursorChange: PropTypes.func,
|
||||||
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
min: PropTypes.number,
|
min: PropTypes.number,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useContext } from 'react'
|
import { useState, useCallback, useContext, useEffect, useRef } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import {
|
import {
|
||||||
Flex,
|
Flex,
|
||||||
@ -108,7 +108,8 @@ const TemplateEditor = ({
|
|||||||
form,
|
form,
|
||||||
setObjectData
|
setObjectData
|
||||||
}) => {
|
}) => {
|
||||||
const { fetchTemplateFormat } = useContext(ApiServerContext)
|
const { fetchTemplateFormat, fetchTemplateIntellisense } =
|
||||||
|
useContext(ApiServerContext)
|
||||||
const currentTemplate = template || objectData
|
const currentTemplate = template || objectData
|
||||||
const [testObjectOpen, setTestObjectOpen] = useState(false)
|
const [testObjectOpen, setTestObjectOpen] = useState(false)
|
||||||
const [testObjectViewMode, setTestObjectViewMode] = useState('Tree')
|
const [testObjectViewMode, setTestObjectViewMode] = useState('Tree')
|
||||||
@ -116,6 +117,70 @@ const TemplateEditor = ({
|
|||||||
const [previewError, setPreviewError] = useState(false)
|
const [previewError, setPreviewError] = useState(false)
|
||||||
const [errorLines, setErrorLines] = useState([])
|
const [errorLines, setErrorLines] = useState([])
|
||||||
const [formatLoading, setFormatLoading] = useState(false)
|
const [formatLoading, setFormatLoading] = useState(false)
|
||||||
|
const [intellisense, setIntellisense] = useState(null)
|
||||||
|
const intellisenseRequestIdRef = useRef(0)
|
||||||
|
const intellisenseTimerRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIntellisense(null)
|
||||||
|
}, [currentTemplate?._id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intellisenseTimerRef.current) {
|
||||||
|
window.clearTimeout(intellisenseTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCursorChange = useCallback(
|
||||||
|
(cursor) => {
|
||||||
|
const templateId = currentTemplate?._id
|
||||||
|
if (!templateId || !isEditing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++intellisenseRequestIdRef.current
|
||||||
|
if (intellisenseTimerRef.current) {
|
||||||
|
window.clearTimeout(intellisenseTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
intellisenseTimerRef.current = window.setTimeout(() => {
|
||||||
|
fetchTemplateIntellisense(
|
||||||
|
templateId,
|
||||||
|
cursor?.content ?? currentTemplate?.content,
|
||||||
|
currentTemplate?.testObject,
|
||||||
|
{
|
||||||
|
offset: cursor?.offset,
|
||||||
|
line: cursor?.line,
|
||||||
|
column: cursor?.column
|
||||||
|
},
|
||||||
|
templateType,
|
||||||
|
(result) => {
|
||||||
|
if (requestId !== intellisenseRequestIdRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result?.intellisense != null &&
|
||||||
|
typeof result.intellisense === 'object'
|
||||||
|
) {
|
||||||
|
if (Object.keys(result.intellisense).length > 0) {
|
||||||
|
setIntellisense(result.intellisense)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentTemplate?._id,
|
||||||
|
currentTemplate?.content,
|
||||||
|
currentTemplate?.testObject,
|
||||||
|
fetchTemplateIntellisense,
|
||||||
|
isEditing,
|
||||||
|
templateType
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
//const isMobile = useMediaQuery({ maxWidth: 768 })
|
//const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
@ -301,8 +366,12 @@ const TemplateEditor = ({
|
|||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
autoCompleteObject={currentTemplate?.testObject}
|
autoCompleteObject={
|
||||||
|
intellisense ?? currentTemplate?.testObject
|
||||||
|
}
|
||||||
errorLines={errorLines}
|
errorLines={errorLines}
|
||||||
|
templateType={templateType}
|
||||||
|
onCursorChange={handleCursorChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@ -25,7 +25,8 @@ export function toEditorCode(code, language = 'javascript') {
|
|||||||
|
|
||||||
export function getCodeLanguageExtension(
|
export function getCodeLanguageExtension(
|
||||||
language = 'javascript',
|
language = 'javascript',
|
||||||
autoCompleteObject = null
|
autoCompleteObject = null,
|
||||||
|
templateType
|
||||||
) {
|
) {
|
||||||
const lang = Array.isArray(language) ? language[0] : language
|
const lang = Array.isArray(language) ? language[0] : language
|
||||||
|
|
||||||
@ -42,7 +43,7 @@ export function getCodeLanguageExtension(
|
|||||||
return xml()
|
return xml()
|
||||||
case 'fctemplatelang':
|
case 'fctemplatelang':
|
||||||
case 'ejs':
|
case 'ejs':
|
||||||
return fcTemplateLang({ autoCompleteObject })
|
return fcTemplateLang({ autoCompleteObject, templateType })
|
||||||
case 'html':
|
case 'html':
|
||||||
return html()
|
return html()
|
||||||
case 'css':
|
case 'css':
|
||||||
|
|||||||
@ -2170,10 +2170,56 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = getTemplateErrorFromResponse(err)
|
const error = getTemplateErrorFromResponse(err)
|
||||||
logger.error('Error fetching template preview:', error)
|
logger.error('Error fetching template preview:', error)
|
||||||
|
const payload = { error }
|
||||||
if (typeof callback === 'function') {
|
if (typeof callback === 'function') {
|
||||||
callback({ error })
|
callback(payload)
|
||||||
}
|
}
|
||||||
return { error }
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTemplateIntellisense = async (
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
testObject,
|
||||||
|
cursor,
|
||||||
|
templateType = 'documentTemplate',
|
||||||
|
callback
|
||||||
|
) => {
|
||||||
|
logger.debug('Fetching template intellisense...')
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${config.backendUrl}/${getObjectEndpoint(templateType)}/${id}/intellisense`,
|
||||||
|
{
|
||||||
|
content,
|
||||||
|
testObject,
|
||||||
|
cursor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(response.data)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
const error = getTemplateErrorFromResponse(err)
|
||||||
|
const intellisense = err?.response?.data?.intellisense
|
||||||
|
logger.error('Error fetching template intellisense:', error)
|
||||||
|
const payload = {
|
||||||
|
error,
|
||||||
|
...(intellisense != null && typeof intellisense === 'object'
|
||||||
|
? { intellisense }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2886,6 +2932,7 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
exportToExcel,
|
exportToExcel,
|
||||||
exportToCsv,
|
exportToCsv,
|
||||||
fetchTemplatePreview,
|
fetchTemplatePreview,
|
||||||
|
fetchTemplateIntellisense,
|
||||||
fetchTemplateFormat,
|
fetchTemplateFormat,
|
||||||
fetchTemplatePDF,
|
fetchTemplatePDF,
|
||||||
fetchTemplateDownload,
|
fetchTemplateDownload,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user