diff --git a/src/main/resources/static/css/customAssets.css b/src/main/resources/static/css/customAssets.css index 4d2cf52..af9dcd6 100644 --- a/src/main/resources/static/css/customAssets.css +++ b/src/main/resources/static/css/customAssets.css @@ -118,6 +118,46 @@ resize: vertical; } +.modal .modal-inner .code-editor.CodeMirror { + height: 420px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; + font-size: 14px; +} + +.modal .modal-inner .code-editor.CodeMirror-focused { + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); +} + +.modal .modal-inner .code-editor .CodeMirror-cursor { + border-left: 2px solid #e2e8f0; +} + +.modal .modal-inner .code-editor .CodeMirror-selected { + background: rgba(124, 58, 237, 0.35); +} + +.modal .modal-inner .code-editor .CodeMirror-gutters { + background: #0b1220; + border-right: 1px solid #1f2937; +} + +.modal .modal-inner .code-editor .CodeMirror-linenumber { + color: rgba(148, 163, 184, 0.8); +} + +.modal .modal-inner .code-editor.CodeMirror-disabled { + background: #020617; + color: #64748b; +} + +.modal .modal-inner .code-editor .CodeMirror-placeholder { + color: #475569; +} + .modal .modal-inner .form-error { padding: 8px; background-color: rgba(200, 0, 0, 0.3); diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index 5ff89a0..abfb85b 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -21,6 +21,7 @@ export function createCustomAssetModal({ const logoPreview = document.getElementById("custom-asset-logo-preview"); const logoClearButton = document.getElementById("custom-asset-logo-clear"); const userSourceTextArea = document.getElementById("custom-asset-code"); + let codeEditor = null; const formErrorWrapper = document.getElementById("custom-asset-error"); const jsErrorTitle = document.getElementById("js-error-title"); const jsErrorDetails = document.getElementById("js-error-details"); @@ -47,6 +48,187 @@ export function createCustomAssetModal({ } }; + const registerCodeEditorLint = () => { + const CodeMirror = globalThis.CodeMirror; + if (!CodeMirror?.registerHelper || CodeMirror.__customAssetLintRegistered) { + return; + } + CodeMirror.__customAssetLintRegistered = true; + CodeMirror.registerHelper("lint", "javascript", (text) => { + const parser = globalThis.acorn; + if (!parser) { + return []; + } + if (!text.trim()) { + return []; + } + + let ast; + try { + ast = parser.parse(text, { + ecmaVersion: "latest", + sourceType: "script", + locations: true, + }); + } catch (e) { + const line = Math.max(0, (e.loc?.line || 1) - 1); + const ch = Math.max(0, e.loc?.column || 0); + return [ + { + from: CodeMirror.Pos(line, ch), + to: CodeMirror.Pos(line, ch + 1), + message: e.message, + severity: "error", + }, + ]; + } + + let hasInit = false; + let hasTick = false; + + const isFunctionNode = (node) => + node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression"); + + const markFunctionName = (name) => { + if (name === "init") hasInit = true; + if (name === "tick") hasTick = true; + }; + + const isModuleExportsMember = (node) => + node && + node.type === "MemberExpression" && + node.object?.type === "Identifier" && + node.object.name === "module" && + node.property?.type === "Identifier" && + node.property.name === "exports"; + + const checkObjectExpression = (objectExpression) => { + if (!objectExpression || objectExpression.type !== "ObjectExpression") { + return; + } + for (const property of objectExpression.properties || []) { + if (property.type !== "Property") { + continue; + } + const keyName = property.key?.type === "Identifier" ? property.key.name : property.key?.value; + if (keyName && isFunctionNode(property.value)) { + markFunctionName(keyName); + } + } + }; + + for (const node of ast.body) { + if (node.type === "FunctionDeclaration") { + markFunctionName(node.id?.name); + continue; + } + + if (node.type !== "ExpressionStatement") continue; + + const expr = node.expression; + if (expr.type !== "AssignmentExpression") continue; + + const left = expr.left; + const right = expr.right; + + if (left.type === "Identifier" && left.name === "exports" && right.type === "ObjectExpression") { + checkObjectExpression(right); + continue; + } + + if (isModuleExportsMember(left) && right.type === "ObjectExpression") { + checkObjectExpression(right); + continue; + } + + if (left.type === "MemberExpression" && left.property.type === "Identifier" && isFunctionNode(right)) { + if ( + (left.object.type === "Identifier" && left.object.name === "exports") || + isModuleExportsMember(left.object) + ) { + markFunctionName(left.property.name); + } + } + } + + const annotations = []; + if (!hasInit) { + annotations.push({ + from: CodeMirror.Pos(0, 0), + to: CodeMirror.Pos(0, 1), + message: "Missing function: init", + severity: "error", + }); + } + + if (!hasTick) { + annotations.push({ + from: CodeMirror.Pos(0, 0), + to: CodeMirror.Pos(0, 1), + message: "Missing function: tick", + severity: "error", + }); + } + + return annotations; + }); + }; + + const createCodeEditor = () => { + const CodeMirror = globalThis.CodeMirror; + if (!CodeMirror || !userSourceTextArea) { + return; + } + + registerCodeEditorLint(); + codeEditor = CodeMirror.fromTextArea(userSourceTextArea, { + mode: "javascript", + lineNumbers: true, + lineWrapping: true, + indentUnit: 2, + tabSize: 2, + gutters: ["CodeMirror-lint-markers"], + lint: true, + placeholder: userSourceTextArea.placeholder, + }); + codeEditor.getWrapperElement().classList.add("code-editor"); + codeEditor.setSize(null, "420px"); + codeEditor.on("change", () => { + userSourceTextArea.value = codeEditor.getValue(); + }); + }; + + const getCodeSource = () => (codeEditor ? codeEditor.getValue() : userSourceTextArea?.value); + + const setCodeValue = (value) => { + if (codeEditor) { + codeEditor.setValue(value ?? ""); + codeEditor.save(); + codeEditor.refresh(); + } else if (userSourceTextArea) { + userSourceTextArea.value = value ?? ""; + } + }; + + const setCodeReadOnly = (isReadOnly) => { + if (codeEditor) { + codeEditor.setOption("readOnly", isReadOnly ? "nocursor" : false); + codeEditor.refresh(); + } + if (userSourceTextArea) { + userSourceTextArea.disabled = isReadOnly; + } + }; + + const setCodePlaceholder = (placeholder) => { + if (codeEditor) { + codeEditor.setOption("placeholder", placeholder); + } + if (userSourceTextArea) { + userSourceTextArea.placeholder = placeholder; + } + }; + const openLaunchModal = () => { launchModal?.classList.remove("hidden"); }; @@ -92,12 +274,13 @@ export function createCustomAssetModal({ } resetLogoState(); if (userSourceTextArea) { - userSourceTextArea.value = ""; - userSourceTextArea.disabled = false; userSourceTextArea.dataset.assetId = ""; - userSourceTextArea.placeholder = - "function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};"; } + setCodeValue(""); + setCodeReadOnly(false); + setCodePlaceholder( + "function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};", + ); setAttachmentState(null, []); resetErrors(); openModal(); @@ -125,11 +308,11 @@ export function createCustomAssetModal({ logoPreview.appendChild(img); } if (userSourceTextArea) { - userSourceTextArea.value = ""; - userSourceTextArea.placeholder = "Loading script..."; - userSourceTextArea.disabled = true; userSourceTextArea.dataset.assetId = asset.id; } + setCodeValue(""); + setCodeReadOnly(true); + setCodePlaceholder("Loading script..."); setAttachmentState(asset.id, asset.scriptAttachments || []); openModal(); @@ -141,23 +324,19 @@ export function createCustomAssetModal({ return response.text(); }) .then((text) => { - if (userSourceTextArea) { - userSourceTextArea.disabled = false; - userSourceTextArea.value = text; - } + setCodeReadOnly(false); + setCodeValue(text); }) .catch(() => { - if (userSourceTextArea) { - userSourceTextArea.disabled = false; - userSourceTextArea.value = ""; - } + setCodeReadOnly(false); + setCodeValue(""); showToast?.("Unable to load script content.", "error"); }); }; const handleFormSubmit = (formEvent) => { formEvent.preventDefault(); - const src = userSourceTextArea?.value; + const src = getCodeSource(); const error = getUserJavaScriptSourceError(src); if (error) { if (jsErrorTitle) { @@ -169,6 +348,7 @@ export function createCustomAssetModal({ if (formErrorWrapper) { formErrorWrapper.classList.remove("hidden"); } + codeEditor?.performLint?.(); return false; } resetErrors(); @@ -222,6 +402,8 @@ export function createCustomAssetModal({ return false; }; + createCodeEditor(); + if (launchModal) { launchModal.addEventListener("click", (event) => { if (event.target === launchModal) { diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 58586fc..fbb5c81 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -8,6 +8,8 @@ + + + + + +