355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
import { app } from "/scripts/app.js";
|
|
import { attachAutocomplete, escapeHtml, handleEditingKey, renderHighlight, EDITOR_SETTINGS } from "./autocomplete.js";
|
|
|
|
const STYLE_ID = "mrmth-script-line-numbers-unified";
|
|
|
|
// `widget.element` of every mrmth_ScriptInput script widget; only these get enhanced.
|
|
const scriptTextareas = new WeakSet();
|
|
const editors = new WeakMap();
|
|
|
|
if (!window.__mrmthScriptInputUnifiedInit) {
|
|
window.__mrmthScriptInputUnifiedInit = true;
|
|
|
|
function ensureStyles() {
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
/* Both node renderers mount the widget into a sized container, so the editor just fills it. */
|
|
.mrmth-editor {
|
|
position: relative;
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
box-sizing: border-box;
|
|
border: 1px solid rgba(255,255,255,0.14);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
background: rgba(0,0,0,0.18);
|
|
}
|
|
|
|
.mrmth-gutter {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
width: 44px;
|
|
text-align: right;
|
|
user-select: none;
|
|
color: #9aa0a6;
|
|
background: rgba(0,0,0,0.35);
|
|
border-right: 1px solid rgba(255,255,255,0.12);
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mrmth-gutter-content {
|
|
padding: 0 6px;
|
|
white-space: pre;
|
|
will-change: transform;
|
|
}
|
|
|
|
.mrmth-editor-container {
|
|
position: absolute;
|
|
inset: 0 0 0 44px;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mrmth-syntax-layer,
|
|
.mrmth-measure {
|
|
margin: 0 !important;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
box-sizing: border-box;
|
|
border-style: solid;
|
|
border-color: transparent;
|
|
}
|
|
|
|
.mrmth-syntax-layer {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 3;
|
|
pointer-events: none;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Extra scroll room so the last line can scroll as far as in the textarea */
|
|
.mrmth-syntax-layer::after {
|
|
content: "";
|
|
display: block;
|
|
height: 1em;
|
|
}
|
|
|
|
.mrmth-syntax-layer span {
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
hyphens: none;
|
|
}
|
|
|
|
.mrmth-measure {
|
|
position: absolute;
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
inset: 0 auto auto 0;
|
|
height: auto;
|
|
}
|
|
|
|
/* Scoped to the editor so a textarea that ever ends up outside it stays readable. */
|
|
.mrmth-editor-container > .mrmth-script-textarea {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
line-height: 1.45;
|
|
|
|
color: transparent !important;
|
|
caret-color: #ffffff !important;
|
|
background: transparent !important;
|
|
-webkit-text-fill-color: transparent;
|
|
|
|
/* Reserve scrollbar space to avoid layout jumps when it appears */
|
|
overflow-y: auto;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
.mrmth-token-comment { color: #7f8c8d; }
|
|
.mrmth-token-number { color: #f39c12; }
|
|
.mrmth-token-constant { color: #f1c40f; }
|
|
.mrmth-token-keyword { color: #e74c3c; }
|
|
.mrmth-token-function { color: #8e44ad; }
|
|
.mrmth-token-variable { color: #ecf0f1; }
|
|
.mrmth-token-operator { color: #95a5a6; }
|
|
.mrmth-token-string { color: #4ea658; }
|
|
.mrmth-token-bracket { font-weight: 700; }
|
|
.mrmth-bracket-0 { color: #ffd700; }
|
|
.mrmth-bracket-1 { color: #da70d6; }
|
|
.mrmth-bracket-2 { color: #87cefa; }
|
|
.mrmth-bracket-3 { color: #98fb98; }
|
|
.mrmth-bracket-4 { color: #ff6347; }
|
|
.mrmth-bracket-5 { color: #ffa500; }
|
|
.mrmth-bracket-unmatched {
|
|
color: #ff4d4d;
|
|
text-decoration: underline wavy #ff4d4d;
|
|
}
|
|
.mrmth-bracket-active {
|
|
outline: 1px solid rgba(255,255,255,0.6);
|
|
border-radius: 2px;
|
|
background: rgba(255,255,255,0.12);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function hideNearbyScriptLabels(textarea) {
|
|
const root = textarea.closest("div, section, article") ?? textarea.parentElement;
|
|
if (!root) return;
|
|
const labels = root.querySelectorAll("label, .label, .input-label, .property_name, .v-label, span, div");
|
|
for (const el of labels) {
|
|
if (el.dataset.mrmthHiddenLabel === "1") continue;
|
|
if ((el.textContent || "").trim().toLowerCase() === "script") {
|
|
el.style.display = "none";
|
|
el.dataset.mrmthHiddenLabel = "1";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy everything that affects text layout so overlay, mirror and textarea wrap identically.
|
|
function syncTextStyle(cs, el) {
|
|
el.style.fontFamily = cs.fontFamily;
|
|
el.style.fontSize = cs.fontSize;
|
|
el.style.fontWeight = cs.fontWeight;
|
|
el.style.fontStyle = cs.fontStyle;
|
|
el.style.letterSpacing = cs.letterSpacing;
|
|
el.style.lineHeight = cs.lineHeight;
|
|
el.style.tabSize = cs.tabSize;
|
|
el.style.padding = `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`;
|
|
el.style.borderWidth = `${cs.borderTopWidth} ${cs.borderRightWidth} ${cs.borderBottomWidth} ${cs.borderLeftWidth}`;
|
|
}
|
|
|
|
function updateLineNumbers(textarea, gutterContent, measureEl, lineHeightPx) {
|
|
const lines = textarea.value.split("\n");
|
|
// Empty lines need content to produce a line box in the mirror.
|
|
measureEl.innerHTML = lines.map((l) => `<span class="mrmth-ln-marker"></span>${escapeHtml(l || " ")}`).join("\n");
|
|
|
|
const markers = measureEl.querySelectorAll(".mrmth-ln-marker");
|
|
const contentBottom = measureEl.clientHeight - parseFloat(getComputedStyle(measureEl).paddingBottom);
|
|
const gutterLines = [];
|
|
for (let i = 0; i < markers.length; i++) {
|
|
const y0 = markers[i].offsetTop;
|
|
const y1 = i + 1 < markers.length ? markers[i + 1].offsetTop : contentBottom;
|
|
gutterLines.push(String(i + 1));
|
|
const visualLines = Math.max(1, Math.round((y1 - y0) / lineHeightPx));
|
|
for (let j = 1; j < visualLines; j++) gutterLines.push("");
|
|
}
|
|
gutterContent.textContent = gutterLines.join("\n");
|
|
}
|
|
|
|
function attachToTextarea(textarea) {
|
|
if (!scriptTextareas.has(textarea)) return;
|
|
|
|
// The frontend moves the bare widget.element into a fresh container when
|
|
// switching between classic and Nodes 2.0 rendering (and on re-mounts);
|
|
// bring the gutter and highlight overlay along, or the textarea is left
|
|
// showing transparent text.
|
|
const existing = editors.get(textarea);
|
|
if (existing) {
|
|
if (!existing.wrapper.contains(textarea)) {
|
|
textarea.replaceWith(existing.wrapper);
|
|
existing.container.appendChild(textarea);
|
|
requestAnimationFrame(existing.refresh);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!textarea.parentElement) return;
|
|
|
|
ensureStyles();
|
|
hideNearbyScriptLabels(textarea);
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "mrmth-editor";
|
|
|
|
const gutter = document.createElement("div");
|
|
gutter.className = "mrmth-gutter";
|
|
|
|
const gutterContent = document.createElement("div");
|
|
gutterContent.className = "mrmth-gutter-content";
|
|
gutter.appendChild(gutterContent);
|
|
|
|
const editorContainer = document.createElement("div");
|
|
editorContainer.className = "mrmth-editor-container";
|
|
|
|
const measureEl = document.createElement("pre");
|
|
measureEl.className = "mrmth-measure";
|
|
|
|
const syntaxLayer = document.createElement("pre");
|
|
syntaxLayer.className = "mrmth-syntax-layer";
|
|
|
|
textarea.replaceWith(wrapper);
|
|
wrapper.appendChild(gutter);
|
|
wrapper.appendChild(editorContainer);
|
|
editorContainer.appendChild(measureEl);
|
|
editorContainer.appendChild(syntaxLayer);
|
|
editorContainer.appendChild(textarea);
|
|
|
|
textarea.classList.add("mrmth-script-textarea");
|
|
textarea.spellcheck = false;
|
|
textarea.autocapitalize = "off";
|
|
textarea.autocomplete = "off";
|
|
textarea.autocorrect = "off";
|
|
|
|
// Autocomplete first: its keydown handler must run before the editing keys.
|
|
const autocomplete = attachAutocomplete(textarea, (id) => app.extensionManager?.setting?.get(id));
|
|
textarea.addEventListener("keydown", (e) => {
|
|
if (handleEditingKey(textarea, e)) e.preventDefault();
|
|
});
|
|
|
|
const syncScroll = () => {
|
|
gutterContent.style.transform = `translateY(${-textarea.scrollTop}px)`;
|
|
syntaxLayer.scrollTop = textarea.scrollTop;
|
|
syntaxLayer.scrollLeft = textarea.scrollLeft;
|
|
};
|
|
|
|
const updateHighlight = () => {
|
|
const focused = document.activeElement === textarea && textarea.selectionStart === textarea.selectionEnd;
|
|
syntaxLayer.innerHTML = renderHighlight(textarea.value, focused ? textarea.selectionStart : -2);
|
|
syncScroll();
|
|
};
|
|
|
|
const refresh = () => {
|
|
const cs = getComputedStyle(textarea);
|
|
syncTextStyle(cs, syntaxLayer);
|
|
syncTextStyle(cs, measureEl);
|
|
gutterContent.style.fontFamily = cs.fontFamily;
|
|
gutterContent.style.fontSize = cs.fontSize;
|
|
gutterContent.style.lineHeight = cs.lineHeight;
|
|
gutterContent.style.paddingTop = `${parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth)}px`;
|
|
|
|
// Same border-box width as the textarea minus its scrollbar, so text wraps identically.
|
|
const widthPx = textarea.clientWidth + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
|
|
measureEl.style.width = `${widthPx}px`;
|
|
syntaxLayer.style.width = `${widthPx}px`;
|
|
|
|
const lineHeightPx = parseFloat(cs.lineHeight);
|
|
updateLineNumbers(textarea, gutterContent, measureEl, lineHeightPx);
|
|
updateHighlight();
|
|
};
|
|
|
|
const onSelectionChange = () => {
|
|
if (document.activeElement === textarea) updateHighlight();
|
|
};
|
|
|
|
textarea.addEventListener("input", refresh);
|
|
textarea.addEventListener("scroll", syncScroll);
|
|
textarea.addEventListener("blur", updateHighlight);
|
|
document.addEventListener("selectionchange", onSelectionChange);
|
|
|
|
const ro = new ResizeObserver(refresh);
|
|
ro.observe(editorContainer);
|
|
|
|
editors.set(textarea, {
|
|
wrapper,
|
|
container: editorContainer,
|
|
refresh,
|
|
destroy() {
|
|
ro.disconnect();
|
|
autocomplete.destroy();
|
|
document.removeEventListener("selectionchange", onSelectionChange);
|
|
},
|
|
});
|
|
|
|
requestAnimationFrame(refresh);
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "mrmth.ScriptTextInput.Unified",
|
|
settings: EDITOR_SETTINGS,
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "mrmth_ScriptInput" && node.type !== "mrmth_ScriptInput") return;
|
|
const widget = node.widgets?.find(w => w.name === "script");
|
|
if (!(widget?.element instanceof HTMLTextAreaElement)) return;
|
|
|
|
// No Vue component is registered for this type, so Nodes 2.0 mounts the
|
|
// DOM widget's own textarea, which is then enhanced like in the classic UI.
|
|
widget.type = "scriptinput";
|
|
|
|
const textarea = widget.element;
|
|
scriptTextareas.add(textarea);
|
|
if (textarea.isConnected) attachToTextarea(textarea);
|
|
|
|
// Loading a workflow sets the value without an input event.
|
|
const onConfigure = node.onConfigure;
|
|
node.onConfigure = function () {
|
|
const r = onConfigure?.apply(this, arguments);
|
|
const editor = editors.get(textarea);
|
|
if (editor) requestAnimationFrame(editor.refresh);
|
|
return r;
|
|
};
|
|
|
|
const onRemoved = node.onRemoved;
|
|
node.onRemoved = function () {
|
|
editors.get(textarea)?.destroy();
|
|
editors.delete(textarea);
|
|
return onRemoved?.apply(this, arguments);
|
|
};
|
|
},
|
|
async setup() {
|
|
// Script widgets are mounted into the DOM after nodeCreated; enhance them once they appear.
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
for (const node of m.addedNodes) {
|
|
if (!(node instanceof Element)) continue;
|
|
if (node instanceof HTMLTextAreaElement) attachToTextarea(node);
|
|
else node.querySelectorAll("textarea").forEach(attachToTextarea);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
},
|
|
});
|
|
}
|