Custom editor

This commit is contained in:
2026-01-10 02:14:52 +01:00
parent 2d5c21e7aa
commit 8973b66626
3 changed files with 244 additions and 16 deletions

View File

@@ -118,6 +118,46 @@
resize: vertical; 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 { .modal .modal-inner .form-error {
padding: 8px; padding: 8px;
background-color: rgba(200, 0, 0, 0.3); background-color: rgba(200, 0, 0, 0.3);

View File

@@ -21,6 +21,7 @@ export function createCustomAssetModal({
const logoPreview = document.getElementById("custom-asset-logo-preview"); const logoPreview = document.getElementById("custom-asset-logo-preview");
const logoClearButton = document.getElementById("custom-asset-logo-clear"); const logoClearButton = document.getElementById("custom-asset-logo-clear");
const userSourceTextArea = document.getElementById("custom-asset-code"); const userSourceTextArea = document.getElementById("custom-asset-code");
let codeEditor = null;
const formErrorWrapper = document.getElementById("custom-asset-error"); const formErrorWrapper = document.getElementById("custom-asset-error");
const jsErrorTitle = document.getElementById("js-error-title"); const jsErrorTitle = document.getElementById("js-error-title");
const jsErrorDetails = document.getElementById("js-error-details"); 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 = () => { const openLaunchModal = () => {
launchModal?.classList.remove("hidden"); launchModal?.classList.remove("hidden");
}; };
@@ -92,12 +274,13 @@ export function createCustomAssetModal({
} }
resetLogoState(); resetLogoState();
if (userSourceTextArea) { if (userSourceTextArea) {
userSourceTextArea.value = "";
userSourceTextArea.disabled = false;
userSourceTextArea.dataset.assetId = ""; 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, []); setAttachmentState(null, []);
resetErrors(); resetErrors();
openModal(); openModal();
@@ -125,11 +308,11 @@ export function createCustomAssetModal({
logoPreview.appendChild(img); logoPreview.appendChild(img);
} }
if (userSourceTextArea) { if (userSourceTextArea) {
userSourceTextArea.value = "";
userSourceTextArea.placeholder = "Loading script...";
userSourceTextArea.disabled = true;
userSourceTextArea.dataset.assetId = asset.id; userSourceTextArea.dataset.assetId = asset.id;
} }
setCodeValue("");
setCodeReadOnly(true);
setCodePlaceholder("Loading script...");
setAttachmentState(asset.id, asset.scriptAttachments || []); setAttachmentState(asset.id, asset.scriptAttachments || []);
openModal(); openModal();
@@ -141,23 +324,19 @@ export function createCustomAssetModal({
return response.text(); return response.text();
}) })
.then((text) => { .then((text) => {
if (userSourceTextArea) { setCodeReadOnly(false);
userSourceTextArea.disabled = false; setCodeValue(text);
userSourceTextArea.value = text;
}
}) })
.catch(() => { .catch(() => {
if (userSourceTextArea) { setCodeReadOnly(false);
userSourceTextArea.disabled = false; setCodeValue("");
userSourceTextArea.value = "";
}
showToast?.("Unable to load script content.", "error"); showToast?.("Unable to load script content.", "error");
}); });
}; };
const handleFormSubmit = (formEvent) => { const handleFormSubmit = (formEvent) => {
formEvent.preventDefault(); formEvent.preventDefault();
const src = userSourceTextArea?.value; const src = getCodeSource();
const error = getUserJavaScriptSourceError(src); const error = getUserJavaScriptSourceError(src);
if (error) { if (error) {
if (jsErrorTitle) { if (jsErrorTitle) {
@@ -169,6 +348,7 @@ export function createCustomAssetModal({
if (formErrorWrapper) { if (formErrorWrapper) {
formErrorWrapper.classList.remove("hidden"); formErrorWrapper.classList.remove("hidden");
} }
codeEditor?.performLint?.();
return false; return false;
} }
resetErrors(); resetErrors();
@@ -222,6 +402,8 @@ export function createCustomAssetModal({
return false; return false;
}; };
createCodeEditor();
if (launchModal) { if (launchModal) {
launchModal.addEventListener("click", (event) => { launchModal.addEventListener("click", (event) => {
if (event.target === launchModal) { if (event.target === launchModal) {

View File

@@ -8,6 +8,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<link rel="stylesheet" href="/css/customAssets.css" /> <link rel="stylesheet" href="/css/customAssets.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/lint/lint.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
@@ -18,6 +20,10 @@
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/acorn@8.15.0/dist/acorn.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/acorn@8.15.0/dist/acorn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/lint/lint.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/display/placeholder.min.js"></script>
<script src="/js/csrf.js"></script> <script src="/js/csrf.js"></script>
</head> </head>
<body class="admin-body"> <body class="admin-body">