import { EditorView } from "@codemirror/view" import { getManifest } from "@budibase/string-templates" import sanitizeHtml from "sanitize-html" import { groupBy } from "lodash" export const EditorModes = { JS: { name: "javascript", json: false, match: /\$$/, }, Handlebars: { name: "handlebars", base: "text/html", match: /{{[\s]*[\w\s]*/, }, Text: { name: "text/html", }, } export const SECTIONS = { HB_HELPER: { name: "Helper", type: "helper", icon: "Code", }, } export const getDefaultTheme = opts => { const { height, resize, dark } = opts return EditorView.theme( { "&.cm-focused .cm-cursor": { borderLeftColor: "var(--spectrum-alias-text-color)", }, "&": { height: height ? `${height}` : "", lineHeight: "1.3", border: "var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)", borderRadius: "var(--border-radius-s)", backgroundColor: "var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )", resize: resize ? `${resize}` : "", overflow: "hidden", color: "var(--spectrum-alias-text-color)", }, "& .cm-tooltip.cm-tooltip-autocomplete > ul": { fontFamily: "var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))", maxHeight: "16em", }, "& .cm-placeholder": { color: "var(--spectrum-alias-text-color)", fontStyle: "italic", }, "&.cm-focused": { outline: "none", borderColor: "var(--spectrum-alias-border-color-mouse-focus)", }, // AUTO COMPLETE "& .cm-completionDetail": { fontStyle: "unset", textTransform: "uppercase", fontSize: "10px", backgroundColor: "var(--spectrum-global-color-gray-100)", color: "var(--spectrum-global-color-gray-600)", }, "& .cm-completionLabel": { marginLeft: "calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))", }, "& .info-bubble": { fontSize: "var(--font-size-s)", display: "grid", gridGap: "var(--spacing-s)", gridTemplateColumns: "1fr", color: "var(--spectrum-global-color-gray-800)", }, "& .cm-tooltip": { marginLeft: "var(--spacing-s)", border: "1px solid var(--spectrum-global-color-gray-300)", borderRadius: "var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )", backgroundColor: "var(--spectrum-global-color-gray-50)", }, // Section header "& .info-section": { display: "flex", padding: "var(--spacing-s)", gap: "var(--spacing-m)", borderBottom: "1px solid var(--spectrum-global-color-gray-200)", color: "var(--spectrum-global-color-gray-800)", fontWeight: "bold", }, "& .info-section .spectrum-Icon": { color: "var(--spectrum-global-color-gray-600)", }, // Autocomplete Option "& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": { display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "var(--spectrum-alias-font-size-default)", padding: "var(--spacing-s)", color: "var(--spectrum-global-color-gray-800)", }, "& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": { backgroundColor: "var(--spectrum-global-color-gray-200)", }, "& .binding-wrap": { color: "var(--spectrum-global-color-blue-700)", fontFamily: "monospace", }, }, { dark } ) } export const buildHelperInfoNode = (completion, helper) => { const ele = document.createElement("div") ele.classList.add("info-bubble") const exampleNodeHtml = helper.example ? `
${helper.example}
` : "" const descriptionMarkup = sanitizeHtml(helper.description, { allowedTags: [], allowedAttributes: {}, }) const descriptionNodeHtml = `
${descriptionMarkup}
` ele.innerHTML = ` ${exampleNodeHtml} ${descriptionNodeHtml} ` return ele } const toSpectrumIcon = name => { return ` ` } export const buildSectionHeader = (type, sectionName, icon, rank) => { const ele = document.createElement("div") ele.classList.add("info-section") ele.classList.add(type) ele.innerHTML = `${toSpectrumIcon(icon)}${sectionName}` return { name: sectionName, header: () => ele, rank, } } export const helpersToCompletion = (helpers, mode) => { const { type, name: sectionName, icon } = SECTIONS.HB_HELPER const helperSection = buildSectionHeader(type, sectionName, icon, 99) return Object.keys(helpers).reduce((acc, key) => { let helper = helpers[key] acc.push({ label: key, info: completion => { return buildHelperInfoNode(completion, helper) }, type: "helper", section: helperSection, detail: "FUNCTION", apply: (view, completion, from, to) => { insertBinding(view, from, to, key, mode) }, }) return acc }, []) } export const getHelperCompletions = mode => { const manifest = getManifest() return Object.keys(manifest).reduce((acc, key) => { acc = acc || [] return [...acc, ...helpersToCompletion(manifest[key], mode)] }, []) } const bindingFilter = (options, query) => { return options.filter(completion => { const section_parsed = completion.section.name.toLowerCase() const label_parsed = completion.label.toLowerCase() const query_parsed = query.toLowerCase() return ( section_parsed.includes(query_parsed) || label_parsed.includes(query_parsed) ) }) } export const hbAutocomplete = baseCompletions => { async function coreCompletion(context) { let bindingStart = context.matchBefore(EditorModes.Handlebars.match) let options = baseCompletions || [] if (!bindingStart) { return null } // Accommodate spaces const match = bindingStart.text.match(/{{[\s]*/) const query = bindingStart.text.replace(match[0], "") let filtered = bindingFilter(options, query) return { from: bindingStart.from + match[0].length, filter: false, options: filtered, } } return coreCompletion } export const jsAutocomplete = baseCompletions => { async function coreCompletion(context) { let jsBinding = context.matchBefore(/\$\("[\s\w]*/) let options = baseCompletions || [] if (jsBinding) { // Accommodate spaces const match = jsBinding.text.match(/\$\("[\s]*/) const query = jsBinding.text.replace(match[0], "") let filtered = bindingFilter(options, query) return { from: jsBinding.from + match[0].length, filter: false, options: filtered, } } return null } return coreCompletion } export const buildBindingInfoNode = (completion, binding) => { const ele = document.createElement("div") ele.classList.add("info-bubble") const exampleNodeHtml = binding.readableBinding ? `
{{ ${binding.readableBinding} }}
` : "" const descriptionNodeHtml = binding.description ? `
${binding.description}
` : "" ele.innerHTML = ` ${exampleNodeHtml} ${descriptionNodeHtml} ` return ele } // Readdress these methods. They shouldn't be used export const hbInsert = (value, from, to, text) => { let parsedInsert = "" const left = from ? value.substring(0, from) : "" const right = to ? value.substring(to) : "" if (!left.includes("{{") || !right.includes("}}")) { parsedInsert = `{{ ${text} }}` } else { parsedInsert = ` ${text} ` } return parsedInsert } export function jsInsert(value, from, to, text, { helper, disableWrapping }) { let parsedInsert = "" const left = from ? value.substring(0, from) : "" const right = to ? value.substring(to) : "" if (disableWrapping) { parsedInsert = text } else if (helper) { parsedInsert = `helpers.${text}()` } else if (!left.includes('$("') || !right.includes('")')) { parsedInsert = `$("${text}")` } else { parsedInsert = text } return parsedInsert } // Autocomplete apply behaviour export const insertBinding = (view, from, to, text, mode) => { let parsedInsert if (mode.name == "javascript") { parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text) } else if (mode.name == "handlebars") { parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) } else { console.warn("Unsupported") return } let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/ let sliced = view.state.doc?.toString().slice(to) const rightBrace = sliced.match(bindingClosePattern) let cursorPos = from + parsedInsert.length if (rightBrace) { cursorPos = from + parsedInsert.length + rightBrace[0].length } view.dispatch({ changes: { from, to, insert: parsedInsert, }, selection: { anchor: cursorPos, }, }) } export const bindingsToCompletions = (bindings, mode) => { const bindingByCategory = groupBy(bindings, "category") const categoryMeta = bindings?.reduce((acc, ele) => { acc[ele.category] = acc[ele.category] || {} if (ele.icon) { acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon } if (typeof ele.display?.rank == "number") { acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank } return acc }, {}) const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => { const { icon, rank } = categoryMeta[catKey] || {} const bindindSectionHeader = buildSectionHeader( bindingByCategory.type, catKey, icon || "", typeof rank == "number" ? rank : 1 ) return [ ...comps, ...bindingByCategory[catKey].reduce((acc, binding) => { let displayType = binding.fieldSchema?.type || binding.display?.type acc.push({ label: binding.display?.name || "NO NAME", info: completion => { return buildBindingInfoNode(completion, binding) }, type: "binding", detail: displayType, section: bindindSectionHeader, apply: (view, completion, from, to) => { insertBinding(view, from, to, binding.readableBinding, mode) }, }) return acc }, []), ] }, []) return completions }