revert(editor): roll back textarea code editor (re-architecture in flight)
The contenteditable + CodeJar + Prism approach (Tasks 1-12 + 4 smoke fixes shipped this session) hit too many contenteditable edge cases to ship: - Copy collapses multi-line selections to one line (Selection.toString() doesn't reliably reconstruct newlines across Prism's tokenized <span> topology). - Enter sometimes requires two presses + cursor color shifts (caret lands "between" sibling tokenized spans; first Enter shifts it into a real text node, second actually inserts). - Cascade of earlier bugs already fixed (cursor jumped to start, then end; popup-accepted-quote duplicated; popup didn't accept at end-of-line) were all symptoms of the same root cause: manual Range API manipulation against tokenized contenteditable DOM is unreliable. Exiting the sunk-cost path before more fixes accrue. The next attempt will be a fresh brainstorming session weighing CodeMirror 6 (battle- tested, accepts a one-time bundler step) vs textarea-overlay (real <textarea> for editing, passive <pre> highlight, no contenteditable). Kept (informs the next attempt): - spec + plan documents in docs/superpowers/ - Playwright scaffolding (conftest + smoke test) + dev deps + e2e marker - scripts/dev-server.py (independent of editor approach) - AGENTS.md sandbox + Chromium Mach-port notes Removed: - editor JS (editor.js, srccfg-grammar.js) - editor CSS (editor.css) - vendored CodeJar + Prism + README - srccfg vocab data - editor partial (_editor_assets.html) - template wiring (data-editor-language attributes, asset partial includes, files-editor language <select>) - files-overlay.js editor bridge (setEditorContent helper, dropdown listener, filename-handler auto-redetect, dropdown reset) - tokens.css syntax-color additions (dead without the editor) - form-contract tests in test_blueprints.py + test_script_overlay_routes.py - the editor-specific Playwright test (test_editor.py) - create-blueprint modal trim that was tied to editor UX (Arguments + Config textareas restored) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ead4bd1aa4
commit
f14d352657
17 changed files with 9 additions and 1291 deletions
|
|
@ -1,81 +0,0 @@
|
||||||
/* Code editor widget — paired with editor.js. Mounted as a sibling of
|
|
||||||
any <textarea data-editor-language>. The textarea is hidden but stays
|
|
||||||
in the DOM for form submission and JS .value reads. */
|
|
||||||
|
|
||||||
/* Token swaps from spec to match tokens.css:
|
|
||||||
--color-fg → --color-text (text color token)
|
|
||||||
--color-bg-input → --color-surface (textarea uses --color-surface)
|
|
||||||
--radius-input → --radius-s (closest available radius token)
|
|
||||||
--color-bg-popover → --color-surface (surface layer for overlays)
|
|
||||||
*/
|
|
||||||
|
|
||||||
.editor-shell {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-code {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 6em;
|
|
||||||
padding: var(--space-s) var(--space-m);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text, #e6e6e6);
|
|
||||||
background: var(--color-surface, #1b1b1b);
|
|
||||||
border: 1px solid var(--color-border, #333);
|
|
||||||
border-radius: var(--radius-s, 4px);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: auto;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-code:focus {
|
|
||||||
border-color: var(--color-focus, #6ab0ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prism token colors — override defaults to match the site palette. */
|
|
||||||
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
|
|
||||||
.editor-code .token.string { color: var(--color-string); }
|
|
||||||
.editor-code .token.keyword { color: var(--color-keyword); font-weight: 600; }
|
|
||||||
.editor-code .token.number { color: var(--color-number); }
|
|
||||||
.editor-code .token.operator { color: var(--color-muted, #888); }
|
|
||||||
.editor-code .token.identifier { color: inherit; }
|
|
||||||
|
|
||||||
/* Autocomplete popup. */
|
|
||||||
.editor-popup {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
max-height: 14em;
|
|
||||||
overflow-y: auto;
|
|
||||||
min-width: 14em;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.25em 0;
|
|
||||||
list-style: none;
|
|
||||||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
||||||
font-size: 0.9em;
|
|
||||||
background: var(--color-surface, #222);
|
|
||||||
border: 1px solid var(--color-border, #333);
|
|
||||||
border-radius: var(--radius-s, 4px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-popup-item {
|
|
||||||
padding: 0.25em 0.75em;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-popup-item.is-active {
|
|
||||||
background: var(--color-bg-popover-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-popup-item .name { color: var(--color-text, #e6e6e6); }
|
|
||||||
.editor-popup-item .desc { color: var(--color-muted, #888); margin-left: 0.5em; }
|
|
||||||
|
|
||||||
/* Files-editor language dropdown. */
|
|
||||||
.editor-language-select {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
@ -13,10 +13,6 @@
|
||||||
--color-focus: #2563eb;
|
--color-focus: #2563eb;
|
||||||
--color-log-bg: #f8fafc;
|
--color-log-bg: #f8fafc;
|
||||||
--color-log-text: #18181b;
|
--color-log-text: #18181b;
|
||||||
--color-string: #0a3069;
|
|
||||||
--color-keyword: #cf222e;
|
|
||||||
--color-number: #0550ae;
|
|
||||||
--color-bg-popover-active: #e5e7eb;
|
|
||||||
|
|
||||||
--space-base: 0.25rem;
|
--space-base: 0.25rem;
|
||||||
--space-xs: var(--space-base);
|
--space-xs: var(--space-base);
|
||||||
|
|
@ -55,10 +51,6 @@
|
||||||
--color-focus: #bfdbfe;
|
--color-focus: #bfdbfe;
|
||||||
--color-log-bg: #111827;
|
--color-log-bg: #111827;
|
||||||
--color-log-text: #e5e7eb;
|
--color-log-text: #e5e7eb;
|
||||||
--color-string: #a5d6ff;
|
|
||||||
--color-keyword: #ff7b72;
|
|
||||||
--color-number: #79c0ff;
|
|
||||||
--color-bg-popover-active: #374151;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"_comment": "Curated L4D2 cvars + commands for editor autocomplete. Regenerate by running `cvarlist` and `cmdlist` against a freshly-started L4D2 dedicated server with the project's common SourceMod plugins loaded, then hand-trimming engine internals nobody touches. Descriptions come from the trailing help text where present.",
|
|
||||||
"cvars": [
|
|
||||||
{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1) — disables VAC"},
|
|
||||||
{"name": "sv_pure", "desc": "Pure-server enforcement (0=off, 1=loose, 2=strict)"},
|
|
||||||
{"name": "sv_consistency", "desc": "Force consistency on every client file (0/1)"},
|
|
||||||
{"name": "sv_alltalk", "desc": "Cross-team voice chat (0/1)"},
|
|
||||||
{"name": "sv_lan", "desc": "LAN-only server (0=internet, 1=LAN)"},
|
|
||||||
{"name": "sv_voiceenable", "desc": "Enable voice chat (0/1)"},
|
|
||||||
{"name": "sv_password", "desc": "Server join password (empty for open)"},
|
|
||||||
{"name": "sv_logflush", "desc": "Flush log file after every line (0/1)"},
|
|
||||||
{"name": "sv_minrate", "desc": "Minimum client bandwidth (bytes/sec)"},
|
|
||||||
{"name": "sv_maxrate", "desc": "Maximum client bandwidth (bytes/sec)"},
|
|
||||||
{"name": "sv_mincmdrate", "desc": "Minimum client command rate"},
|
|
||||||
{"name": "sv_maxcmdrate", "desc": "Maximum client command rate"},
|
|
||||||
{"name": "sv_minupdaterate", "desc": "Minimum server update rate"},
|
|
||||||
{"name": "sv_maxupdaterate", "desc": "Maximum server update rate"},
|
|
||||||
{"name": "sv_region", "desc": "Server browser region code"},
|
|
||||||
{"name": "sv_steamgroup", "desc": "Steam group ID for restricted servers"},
|
|
||||||
{"name": "sv_tags", "desc": "Comma-separated tags for the server browser"},
|
|
||||||
{"name": "hostname", "desc": "Server name shown in the browser"},
|
|
||||||
{"name": "rcon_password", "desc": "Remote-console admin password"},
|
|
||||||
{"name": "mp_gamemode", "desc": "Game mode (coop, versus, survival, scavenge, realism)"},
|
|
||||||
{"name": "mp_roundtime", "desc": "Round time limit (minutes)"},
|
|
||||||
{"name": "z_difficulty", "desc": "AI director difficulty (Easy/Normal/Hard/Impossible)"},
|
|
||||||
{"name": "director_no_specials", "desc": "Disable special-infected spawning (0/1)"},
|
|
||||||
{"name": "director_no_bosses", "desc": "Disable tank/witch spawning (0/1)"},
|
|
||||||
{"name": "director_panic_forever", "desc": "Endless horde panic event (0/1)"},
|
|
||||||
{"name": "nb_update_frequency", "desc": "Infected bot AI tick frequency"},
|
|
||||||
{"name": "fps_max", "desc": "Frame rate cap (0=uncapped)"},
|
|
||||||
{"name": "tickrate", "desc": "Server tickrate (engine-dependent ceiling)"},
|
|
||||||
{"name": "net_splitpacket_maxrate", "desc": "Maximum split-packet bandwidth"},
|
|
||||||
{"name": "decalfrequency", "desc": "Anti-spam delay between sprays (seconds)"}
|
|
||||||
],
|
|
||||||
"commands": [
|
|
||||||
{"name": "exec", "desc": "Execute a .cfg file"},
|
|
||||||
{"name": "alias", "desc": "Define a console-command alias"},
|
|
||||||
{"name": "bind", "desc": "Bind a key to a command"},
|
|
||||||
{"name": "unbind", "desc": "Remove a key binding"},
|
|
||||||
{"name": "toggle", "desc": "Flip a 0/1 cvar"},
|
|
||||||
{"name": "sm_cvar", "desc": "SourceMod: set a cvar bypassing sv_cheats restrictions"},
|
|
||||||
{"name": "echo", "desc": "Print to console"},
|
|
||||||
{"name": "say", "desc": "Send a chat message as the server"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,403 +0,0 @@
|
||||||
// Code editor widget. Mounts on any <textarea data-editor-language>.
|
|
||||||
// The textarea stays in the DOM (display:none) and the widget mirrors
|
|
||||||
// content back into it on every input — form submission and JS code
|
|
||||||
// that reads textarea.value (e.g. files-overlay.js) keep working.
|
|
||||||
//
|
|
||||||
// Public per-instance API (attached to the textarea as ._codeEditor):
|
|
||||||
// - setValue(text)
|
|
||||||
// - setLanguage(name) // "srccfg" | "bash" | "plain" | "auto"
|
|
||||||
// - getValue()
|
|
||||||
// - destroy()
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const LANG_BY_EXT = {
|
|
||||||
cfg: "srccfg",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
};
|
|
||||||
|
|
||||||
const VOCAB_URLS = {
|
|
||||||
srccfg: "/static/data/srccfg-vocab.json",
|
|
||||||
};
|
|
||||||
|
|
||||||
const vocabCache = {};
|
|
||||||
|
|
||||||
async function loadVocab(lang) {
|
|
||||||
if (vocabCache[lang]) return vocabCache[lang];
|
|
||||||
const url = VOCAB_URLS[lang];
|
|
||||||
if (!url) return [];
|
|
||||||
try {
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (!r.ok) return [];
|
|
||||||
const data = await r.json();
|
|
||||||
const merged = []
|
|
||||||
.concat(data.cvars || [])
|
|
||||||
.concat(data.commands || []);
|
|
||||||
vocabCache[lang] = merged;
|
|
||||||
return merged;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[editor] vocab load failed for " + lang, err);
|
|
||||||
vocabCache[lang] = [];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAutoLanguage(filename) {
|
|
||||||
if (!filename) return "plain";
|
|
||||||
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
|
|
||||||
if (!m) return "plain";
|
|
||||||
return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlightFor(lang) {
|
|
||||||
return function (editorEl) {
|
|
||||||
if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
|
|
||||||
// Plain mode or grammar missing: leave textContent alone.
|
|
||||||
editorEl.innerHTML = editorEl.textContent
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.Prism.highlightElement(editorEl);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/;
|
|
||||||
|
|
||||||
function getCaretContext(codeEl) {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel || !sel.rangeCount) return null;
|
|
||||||
const range = sel.getRangeAt(0).cloneRange();
|
|
||||||
if (!codeEl.contains(range.endContainer)) return null;
|
|
||||||
// Build the text from start of the editor up to the caret.
|
|
||||||
const pre = range.cloneRange();
|
|
||||||
pre.selectNodeContents(codeEl);
|
|
||||||
pre.setEnd(range.endContainer, range.endOffset);
|
|
||||||
const textBefore = pre.toString();
|
|
||||||
const m = WORD_BEFORE_CARET.exec(textBefore);
|
|
||||||
if (!m) return null;
|
|
||||||
return {
|
|
||||||
fragment: m[0],
|
|
||||||
rect: range.getBoundingClientRect(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterVocab(vocab, fragment) {
|
|
||||||
const lower = fragment.toLowerCase();
|
|
||||||
const prefix = [];
|
|
||||||
const substr = [];
|
|
||||||
for (const entry of vocab) {
|
|
||||||
const name = entry.name.toLowerCase();
|
|
||||||
if (name.startsWith(lower)) prefix.push(entry);
|
|
||||||
else if (name.includes(lower)) substr.push(entry);
|
|
||||||
if (prefix.length + substr.length >= 50) break;
|
|
||||||
}
|
|
||||||
return prefix.concat(substr).slice(0, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPopup(popup, items, activeIndex) {
|
|
||||||
popup.innerHTML = "";
|
|
||||||
const visible = items.slice(0, 8);
|
|
||||||
visible.forEach((entry, i) => {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : "");
|
|
||||||
li.dataset.index = String(i);
|
|
||||||
const name = document.createElement("span");
|
|
||||||
name.className = "name";
|
|
||||||
name.textContent = entry.name;
|
|
||||||
li.appendChild(name);
|
|
||||||
if (entry.desc) {
|
|
||||||
const desc = document.createElement("span");
|
|
||||||
desc.className = "desc";
|
|
||||||
desc.textContent = "— " + entry.desc;
|
|
||||||
li.appendChild(desc);
|
|
||||||
}
|
|
||||||
popup.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionPopup(popup, rect) {
|
|
||||||
popup.style.left = (window.scrollX + rect.left) + "px";
|
|
||||||
popup.style.top = (window.scrollY + rect.bottom + 2) + "px";
|
|
||||||
}
|
|
||||||
|
|
||||||
// For "auto" language: look for a filename input inside the nearest
|
|
||||||
// enclosing dialog or .modal. Returns the <input> or null.
|
|
||||||
// Intentionally does NOT walk up to <body>: an "auto" textarea
|
|
||||||
// outside a modal scope degrades to "plain" rather than picking up
|
|
||||||
// some unrelated .files-editor-filename elsewhere in the document.
|
|
||||||
function findFilenameInput(textarea) {
|
|
||||||
const scope = textarea.closest("dialog, .modal");
|
|
||||||
if (!scope) return null;
|
|
||||||
return scope.querySelector(".files-editor-filename");
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount(textarea) {
|
|
||||||
if (textarea._codeEditor) return textarea._codeEditor;
|
|
||||||
|
|
||||||
const requested = textarea.dataset.editorLanguage || "plain";
|
|
||||||
let language =
|
|
||||||
requested === "auto"
|
|
||||||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
|
||||||
: requested;
|
|
||||||
|
|
||||||
// Build the visible editor.
|
|
||||||
const shell = document.createElement("div");
|
|
||||||
shell.className = "editor-shell";
|
|
||||||
const code = document.createElement("code");
|
|
||||||
code.className = "editor-code language-" + language;
|
|
||||||
code.setAttribute("contenteditable", "true");
|
|
||||||
code.setAttribute("spellcheck", "false");
|
|
||||||
code.textContent = textarea.value;
|
|
||||||
shell.appendChild(code);
|
|
||||||
textarea.parentNode.insertBefore(shell, textarea);
|
|
||||||
textarea.style.display = "none";
|
|
||||||
|
|
||||||
// CodeJar mounts on the contenteditable and re-runs the highlighter
|
|
||||||
// on each input while preserving caret position.
|
|
||||||
const jar = window.CodeJar(code, highlightFor(language), { tab: " ", addClosing: false });
|
|
||||||
|
|
||||||
function attachOnUpdate(jarInstance) {
|
|
||||||
jarInstance.onUpdate(function (value) {
|
|
||||||
// Mirror back to the underlying textarea so form POST and any
|
|
||||||
// .value readers see the current content.
|
|
||||||
textarea.value = value;
|
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
attachOnUpdate(jar);
|
|
||||||
|
|
||||||
// Warm the vocab cache so the first keystroke doesn't race a network
|
|
||||||
// fetch. Fire-and-forget; loadVocab already caches the empty result
|
|
||||||
// on failure so this is idempotent.
|
|
||||||
if (language !== "plain") loadVocab(language);
|
|
||||||
|
|
||||||
let popup = null;
|
|
||||||
let popupItems = [];
|
|
||||||
let popupActive = 0;
|
|
||||||
|
|
||||||
function ensurePopup() {
|
|
||||||
if (popup) return popup;
|
|
||||||
popup = document.createElement("ul");
|
|
||||||
popup.className = "editor-popup";
|
|
||||||
popup.style.display = "none";
|
|
||||||
document.body.appendChild(popup);
|
|
||||||
popup.addEventListener("mousedown", function (e) {
|
|
||||||
e.preventDefault(); // keep caret in editor
|
|
||||||
const li = e.target.closest(".editor-popup-item");
|
|
||||||
if (!li) return;
|
|
||||||
acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]);
|
|
||||||
});
|
|
||||||
return popup;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hidePopup() {
|
|
||||||
if (popup) popup.style.display = "none";
|
|
||||||
popupItems = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptCompletion(entry) {
|
|
||||||
if (!entry) return;
|
|
||||||
const ctx = getCaretContext(code);
|
|
||||||
if (!ctx) {
|
|
||||||
hidePopup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Walk the selection focus backwards `fragment.length` characters
|
|
||||||
// and replace the result with the chosen identifier.
|
|
||||||
//
|
|
||||||
// We can't use `range.setStart(endContainer, endOffset - fragment.length)`
|
|
||||||
// because at end-of-line the caret often sits in a text node AFTER
|
|
||||||
// Prism's <span class="token …"> (the trailing newline / empty text
|
|
||||||
// node), so `endOffset` is small and the subtraction would go
|
|
||||||
// negative or land in the wrong node. `Selection.modify` walks
|
|
||||||
// across node boundaries the way the browser's own caret-movement
|
|
||||||
// logic does, so it works regardless of which DOM node the caret
|
|
||||||
// ended up in after the last Prism rehighlight.
|
|
||||||
//
|
|
||||||
// (Selection.modify is well-supported in all evergreen browsers
|
|
||||||
// even though it's not standardised — same family of API as
|
|
||||||
// contenteditable itself.)
|
|
||||||
try {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
for (let i = 0; i < ctx.fragment.length; i++) {
|
|
||||||
sel.modify("extend", "backward", "character");
|
|
||||||
}
|
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
range.deleteContents();
|
|
||||||
const insertedNode = document.createTextNode(entry.name);
|
|
||||||
range.insertNode(insertedNode);
|
|
||||||
// Move the caret to inside the inserted text node (at its end),
|
|
||||||
// NOT just "after" it via range.collapse(false). The latter
|
|
||||||
// leaves anchorNode/focusNode pointing at <code> with an offset
|
|
||||||
// — and CodeJar's save() has a special case (codejar.js:122-127)
|
|
||||||
// that collapses any such selection to end-of-all-text, sending
|
|
||||||
// restore() to the wrong place. Pointing the selection inside
|
|
||||||
// the inserted text node keeps save() on its normal walk path.
|
|
||||||
const caretRange = document.createRange();
|
|
||||||
caretRange.setStart(insertedNode, insertedNode.length);
|
|
||||||
caretRange.setEnd(insertedNode, insertedNode.length);
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(caretRange);
|
|
||||||
// Force CodeJar to re-highlight + emit onUpdate. updateCode does
|
|
||||||
// `editor.textContent = code; highlight(editor)` — no caret
|
|
||||||
// preservation — so we save the linear-text offset of the caret
|
|
||||||
// (now positioned after the inserted identifier) before and
|
|
||||||
// restore after. Use instance.jar (not bare `jar` closure) for
|
|
||||||
// consistency with setLanguage's tear-down-and-remount: the jar
|
|
||||||
// reference is swapped at runtime.
|
|
||||||
const caretPos = instance.jar.save();
|
|
||||||
instance.jar.updateCode(instance.jar.toString());
|
|
||||||
instance.jar.restore(caretPos);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[editor] acceptCompletion failed; dismissing popup", err);
|
|
||||||
}
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPopup() {
|
|
||||||
if (instance.language === "plain") {
|
|
||||||
hidePopup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = getCaretContext(code);
|
|
||||||
if (!ctx) {
|
|
||||||
hidePopup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const vocab = await loadVocab(instance.language);
|
|
||||||
if (!vocab.length) {
|
|
||||||
hidePopup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filtered = filterVocab(vocab, ctx.fragment);
|
|
||||||
if (!filtered.length) {
|
|
||||||
hidePopup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
popupItems = filtered;
|
|
||||||
popupActive = 0;
|
|
||||||
ensurePopup();
|
|
||||||
renderPopup(popup, popupItems, popupActive);
|
|
||||||
positionPopup(popup, ctx.rect);
|
|
||||||
popup.style.display = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
code.addEventListener("input", refreshPopup);
|
|
||||||
code.addEventListener("blur", function () {
|
|
||||||
// Defer hide so a popup click can still register.
|
|
||||||
setTimeout(hidePopup, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// IMPORTANT: capture-phase listener.
|
|
||||||
//
|
|
||||||
// CodeJar registers its own keydown handler on this same element during
|
|
||||||
// construction (window.CodeJar at line ~159 above), and it uses
|
|
||||||
// preventDefault + insert-tab-spaces on Tab and preventDefault +
|
|
||||||
// stopPropagation on Enter (with leading indent). Bubble-phase order =
|
|
||||||
// registration order: CodeJar runs first and consumes those keys before
|
|
||||||
// our popup handler ever sees them.
|
|
||||||
//
|
|
||||||
// Capture phase fires before bubble. By calling stopPropagation here,
|
|
||||||
// we prevent CodeJar's bubble handler from running for the keys we own
|
|
||||||
// while the popup is visible. Normal typing (popup hidden) still flows
|
|
||||||
// through to CodeJar unchanged because we early-return without
|
|
||||||
// stopPropagation in that case.
|
|
||||||
code.addEventListener("keydown", function (e) {
|
|
||||||
if (!popup || popup.style.display === "none" || !popupItems.length) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
popupActive = (popupActive + 1) % Math.min(popupItems.length, 8);
|
|
||||||
renderPopup(popup, popupItems, popupActive);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
popupActive =
|
|
||||||
(popupActive - 1 + Math.min(popupItems.length, 8)) %
|
|
||||||
Math.min(popupItems.length, 8);
|
|
||||||
renderPopup(popup, popupItems, popupActive);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
} else if (e.key === "Tab" || e.key === "Enter") {
|
|
||||||
acceptCompletion(popupItems[popupActive]);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
hidePopup();
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
const instance = {
|
|
||||||
textarea,
|
|
||||||
shell,
|
|
||||||
code,
|
|
||||||
jar,
|
|
||||||
language,
|
|
||||||
setValue: function (text) {
|
|
||||||
// updateCode does not invoke the onUpdate mirror, so fire the
|
|
||||||
// same textarea sync + input event here for consistency. Any
|
|
||||||
// listener watching the textarea sees external setValue calls
|
|
||||||
// (e.g. files-overlay loading a file into the modal) the same
|
|
||||||
// way it sees user typing.
|
|
||||||
instance.jar.updateCode(text);
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
},
|
|
||||||
getValue: function () {
|
|
||||||
return instance.jar.toString();
|
|
||||||
},
|
|
||||||
setLanguage: function (name) {
|
|
||||||
const next =
|
|
||||||
name === "auto"
|
|
||||||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
|
||||||
: name;
|
|
||||||
if (next === instance.language) return;
|
|
||||||
// CodeJar captures its highlight callback by closure at
|
|
||||||
// construction time — there is no API to swap it on a live
|
|
||||||
// instance. Tear down and remount with the new highlighter.
|
|
||||||
// Caret position is lost on switch; acceptable since this is
|
|
||||||
// triggered by the user clicking the language dropdown.
|
|
||||||
const currentText = instance.jar.toString();
|
|
||||||
instance.jar.destroy();
|
|
||||||
instance.language = next;
|
|
||||||
code.className = "editor-code language-" + next;
|
|
||||||
code.textContent = currentText;
|
|
||||||
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " ", addClosing: false });
|
|
||||||
attachOnUpdate(instance.jar);
|
|
||||||
},
|
|
||||||
destroy: function () {
|
|
||||||
instance.jar.destroy();
|
|
||||||
if (popup) popup.remove();
|
|
||||||
shell.remove();
|
|
||||||
textarea.style.display = "";
|
|
||||||
delete textarea._codeEditor;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
textarea._codeEditor = instance;
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountAll(root) {
|
|
||||||
const scope = root || document;
|
|
||||||
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
mountAll(document);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
mountAll(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for callers that need to mount editors created later
|
|
||||||
// (the files-editor modal exists in the static DOM but is only
|
|
||||||
// shown after user interaction — initial mount is correct, but
|
|
||||||
// exposing this hook lets future code mount dynamically-inserted
|
|
||||||
// editors if needed).
|
|
||||||
window.l4d2Editor = { mount, mountAll };
|
|
||||||
})();
|
|
||||||
|
|
@ -281,16 +281,6 @@
|
||||||
saveBtn: editorDialog.querySelector(".files-editor-save"),
|
saveBtn: editorDialog.querySelector(".files-editor-save"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function setEditorContent(text) {
|
|
||||||
const editor = editorEls.contentBox._codeEditor;
|
|
||||||
if (editor) {
|
|
||||||
editor.setValue(text);
|
|
||||||
editor.setLanguage("auto"); // re-derive from filename
|
|
||||||
} else {
|
|
||||||
editorEls.contentBox.value = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEditorTitle(text) {
|
function setEditorTitle(text) {
|
||||||
editorEls.title.textContent = text;
|
editorEls.title.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
@ -349,17 +339,10 @@
|
||||||
editor.folder = folder;
|
editor.folder = folder;
|
||||||
editor.queuedReplacement = null;
|
editor.queuedReplacement = null;
|
||||||
|
|
||||||
// Reset the language dropdown to "auto" on every modal open so the
|
|
||||||
// displayed value matches what setEditorContent does internally
|
|
||||||
// (which always calls setLanguage("auto")). Without this, a user
|
|
||||||
// who picked a manual override on a previous open would see the
|
|
||||||
// stale selection while the editor language follows the new file.
|
|
||||||
if (languageSelect) languageSelect.value = "auto";
|
|
||||||
|
|
||||||
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
|
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
|
||||||
editorEls.filename.value = "";
|
editorEls.filename.value = "";
|
||||||
editorEls.filename.disabled = false;
|
editorEls.filename.disabled = false;
|
||||||
setEditorContent("");
|
editorEls.contentBox.value = "";
|
||||||
editorEls.contentBox.disabled = false;
|
editorEls.contentBox.disabled = false;
|
||||||
editorEls.renameHint.hidden = true;
|
editorEls.renameHint.hidden = true;
|
||||||
editorEls.textPanel.hidden = false;
|
editorEls.textPanel.hidden = false;
|
||||||
|
|
@ -380,9 +363,6 @@
|
||||||
editor.queuedReplacement = null;
|
editor.queuedReplacement = null;
|
||||||
setQueuedReplacement(null);
|
setQueuedReplacement(null);
|
||||||
|
|
||||||
// Reset the language dropdown — see openEditorTextNew for rationale.
|
|
||||||
if (languageSelect) languageSelect.value = "auto";
|
|
||||||
|
|
||||||
editorEls.filename.value = basename(path);
|
editorEls.filename.value = basename(path);
|
||||||
editorEls.filename.disabled = false;
|
editorEls.filename.disabled = false;
|
||||||
editorEls.renameHint.hidden = true;
|
editorEls.renameHint.hidden = true;
|
||||||
|
|
@ -395,14 +375,14 @@
|
||||||
editor.mode = "text";
|
editor.mode = "text";
|
||||||
editorEls.textPanel.hidden = false;
|
editorEls.textPanel.hidden = false;
|
||||||
editorEls.binaryPanel.hidden = true;
|
editorEls.binaryPanel.hidden = true;
|
||||||
setEditorContent("Loading…");
|
editorEls.contentBox.value = "Loading…";
|
||||||
editorEls.contentBox.disabled = true;
|
editorEls.contentBox.disabled = true;
|
||||||
|
|
||||||
const r = await fetchJson(
|
const r = await fetchJson(
|
||||||
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
|
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
|
||||||
);
|
);
|
||||||
if (r.ok && r.body) {
|
if (r.ok && r.body) {
|
||||||
setEditorContent(r.body.content);
|
editorEls.contentBox.value = r.body.content;
|
||||||
editorEls.contentBox.disabled = false;
|
editorEls.contentBox.disabled = false;
|
||||||
updateByteCount();
|
updateByteCount();
|
||||||
updateSaveEnabled();
|
updateSaveEnabled();
|
||||||
|
|
@ -427,21 +407,9 @@
|
||||||
setTimeout(() => editorEls.filename.focus(), 0);
|
setTimeout(() => editorEls.filename.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageSelect = document.querySelector(".files-editor-language");
|
|
||||||
if (languageSelect) {
|
|
||||||
languageSelect.addEventListener("change", function () {
|
|
||||||
const editor = editorEls.contentBox._codeEditor;
|
|
||||||
if (editor) editor.setLanguage(languageSelect.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
editorEls.filename.addEventListener("input", () => {
|
editorEls.filename.addEventListener("input", () => {
|
||||||
updateRenameHint();
|
updateRenameHint();
|
||||||
updateSaveEnabled();
|
updateSaveEnabled();
|
||||||
const _editor = editorEls.contentBox._codeEditor;
|
|
||||||
if (_editor && languageSelect && languageSelect.value === "auto") {
|
|
||||||
_editor.setLanguage("auto");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
editorEls.contentBox.addEventListener("input", () => {
|
editorEls.contentBox.addEventListener("input", () => {
|
||||||
updateByteCount();
|
updateByteCount();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// Prism grammar for Source-engine config files (server.cfg-style).
|
|
||||||
// Tokens: comment, string, number, keyword, identifier. Purely visual —
|
|
||||||
// no semantic validation of cvars or values.
|
|
||||||
(function (Prism) {
|
|
||||||
if (!Prism) return;
|
|
||||||
Prism.languages.srccfg = {
|
|
||||||
comment: /\/\/.*/,
|
|
||||||
string: {
|
|
||||||
pattern: /"(?:\\.|[^"\\])*"/,
|
|
||||||
greedy: true,
|
|
||||||
},
|
|
||||||
keyword: /\b(?:exec|alias|bind|unbind|toggle)\b/,
|
|
||||||
number: /\b\d+(?:\.\d+)?\b/,
|
|
||||||
identifier: /\b[a-zA-Z_][a-zA-Z0-9_]*\b/,
|
|
||||||
operator: /[+\-;]/,
|
|
||||||
};
|
|
||||||
})(typeof window !== "undefined" ? window.Prism : undefined);
|
|
||||||
28
l4d2web/l4d2web/static/vendor/README.md
vendored
28
l4d2web/l4d2web/static/vendor/README.md
vendored
|
|
@ -1,28 +0,0 @@
|
||||||
# Vendored static assets
|
|
||||||
|
|
||||||
All third-party JS/CSS shipped under `/static/vendor/` is committed
|
|
||||||
verbatim from the upstream releases below. The strict CSP
|
|
||||||
(`default-src 'self'`) means we cannot load these from CDNs.
|
|
||||||
|
|
||||||
| File | Upstream | Version | SHA256 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `prism.js` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `636b6ce9db1eddd5b60992bb34e3fbc1c3364bce7c312f798f20a16011d7681c` |
|
|
||||||
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `928e23e6b9fcef82c5f1d1f05b6f7fc5a6e187c60195e59fbf16fc9d071ee057` |
|
|
||||||
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js + ESM-strip + attribution header + browser-global shim | v4.0.0 | `1d05dc0fdc6941c3ea0c865612843d098e271155b9258b4cbbc4095b07318d3b` |
|
|
||||||
|
|
||||||
CodeJar ships in source form (not minified) — chosen here over the
|
|
||||||
minified variant because the strict CSP rules out runtime sourcemap
|
|
||||||
loading from a CDN, and readable vendor source meaningfully helps
|
|
||||||
debugging when something goes wrong in the editor widget.
|
|
||||||
|
|
||||||
## Regenerating
|
|
||||||
|
|
||||||
- **Prism:** Re-run the three-component concat in Task 1 Step 1 of
|
|
||||||
`docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with
|
|
||||||
an updated `VER`, then re-download the theme CSS.
|
|
||||||
- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1
|
|
||||||
Step 2 (`dist/codejar.js`, not the bare `codejar.js` which does not
|
|
||||||
exist in v4.x), strip ESM exports, prepend the in-file attribution
|
|
||||||
header, re-append the `window.CodeJar` shim.
|
|
||||||
|
|
||||||
Bump the version + SHA columns in this table after any update.
|
|
||||||
492
l4d2web/l4d2web/static/vendor/codejar.js
vendored
492
l4d2web/l4d2web/static/vendor/codejar.js
vendored
|
|
@ -1,492 +0,0 @@
|
||||||
/* CodeJar v4.0.0 — https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js
|
|
||||||
* Local edits: leading `export` keyword stripped from `function CodeJar`;
|
|
||||||
* `window.CodeJar = CodeJar;` shim appended at EOF for non-module <script> use. */
|
|
||||||
const globalWindow = window;
|
|
||||||
function CodeJar(editor, highlight, opt = {}) {
|
|
||||||
const options = {
|
|
||||||
tab: '\t',
|
|
||||||
indentOn: /[({\[]$/,
|
|
||||||
moveToNewLine: /^[)}\]]/,
|
|
||||||
spellcheck: false,
|
|
||||||
catchTab: true,
|
|
||||||
preserveIdent: true,
|
|
||||||
addClosing: true,
|
|
||||||
history: true,
|
|
||||||
window: globalWindow,
|
|
||||||
...opt
|
|
||||||
};
|
|
||||||
const window = options.window;
|
|
||||||
const document = window.document;
|
|
||||||
let listeners = [];
|
|
||||||
let history = [];
|
|
||||||
let at = -1;
|
|
||||||
let focus = false;
|
|
||||||
let callback;
|
|
||||||
let prev; // code content prior keydown event
|
|
||||||
editor.setAttribute('contenteditable', 'plaintext-only');
|
|
||||||
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
|
|
||||||
editor.style.outline = 'none';
|
|
||||||
editor.style.overflowWrap = 'break-word';
|
|
||||||
editor.style.overflowY = 'auto';
|
|
||||||
editor.style.whiteSpace = 'pre-wrap';
|
|
||||||
let isLegacy = false; // true if plaintext-only is not supported
|
|
||||||
highlight(editor);
|
|
||||||
if (editor.contentEditable !== 'plaintext-only')
|
|
||||||
isLegacy = true;
|
|
||||||
if (isLegacy)
|
|
||||||
editor.setAttribute('contenteditable', 'true');
|
|
||||||
const debounceHighlight = debounce(() => {
|
|
||||||
const pos = save();
|
|
||||||
highlight(editor, pos);
|
|
||||||
restore(pos);
|
|
||||||
}, 30);
|
|
||||||
let recording = false;
|
|
||||||
const shouldRecord = (event) => {
|
|
||||||
return !isUndo(event) && !isRedo(event)
|
|
||||||
&& event.key !== 'Meta'
|
|
||||||
&& event.key !== 'Control'
|
|
||||||
&& event.key !== 'Alt'
|
|
||||||
&& !event.key.startsWith('Arrow');
|
|
||||||
};
|
|
||||||
const debounceRecordHistory = debounce((event) => {
|
|
||||||
if (shouldRecord(event)) {
|
|
||||||
recordHistory();
|
|
||||||
recording = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
const on = (type, fn) => {
|
|
||||||
listeners.push([type, fn]);
|
|
||||||
editor.addEventListener(type, fn);
|
|
||||||
};
|
|
||||||
on('keydown', event => {
|
|
||||||
if (event.defaultPrevented)
|
|
||||||
return;
|
|
||||||
prev = toString();
|
|
||||||
if (options.preserveIdent)
|
|
||||||
handleNewLine(event);
|
|
||||||
else
|
|
||||||
legacyNewLineFix(event);
|
|
||||||
if (options.catchTab)
|
|
||||||
handleTabCharacters(event);
|
|
||||||
if (options.addClosing)
|
|
||||||
handleSelfClosingCharacters(event);
|
|
||||||
if (options.history) {
|
|
||||||
handleUndoRedo(event);
|
|
||||||
if (shouldRecord(event) && !recording) {
|
|
||||||
recordHistory();
|
|
||||||
recording = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isLegacy && !isCopy(event))
|
|
||||||
restore(save());
|
|
||||||
});
|
|
||||||
on('keyup', event => {
|
|
||||||
if (event.defaultPrevented)
|
|
||||||
return;
|
|
||||||
if (event.isComposing)
|
|
||||||
return;
|
|
||||||
if (prev !== toString())
|
|
||||||
debounceHighlight();
|
|
||||||
debounceRecordHistory(event);
|
|
||||||
if (callback)
|
|
||||||
callback(toString());
|
|
||||||
});
|
|
||||||
on('focus', _event => {
|
|
||||||
focus = true;
|
|
||||||
});
|
|
||||||
on('blur', _event => {
|
|
||||||
focus = false;
|
|
||||||
});
|
|
||||||
on('paste', event => {
|
|
||||||
recordHistory();
|
|
||||||
handlePaste(event);
|
|
||||||
recordHistory();
|
|
||||||
if (callback)
|
|
||||||
callback(toString());
|
|
||||||
});
|
|
||||||
on('cut', event => {
|
|
||||||
recordHistory();
|
|
||||||
handleCut(event);
|
|
||||||
recordHistory();
|
|
||||||
if (callback)
|
|
||||||
callback(toString());
|
|
||||||
});
|
|
||||||
function save() {
|
|
||||||
const s = getSelection();
|
|
||||||
const pos = { start: 0, end: 0, dir: undefined };
|
|
||||||
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
|
|
||||||
if (!anchorNode || !focusNode)
|
|
||||||
throw 'error1';
|
|
||||||
// If the anchor and focus are the editor element, return either a full
|
|
||||||
// highlight or a start/end cursor position depending on the selection
|
|
||||||
if (anchorNode === editor && focusNode === editor) {
|
|
||||||
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
|
||||||
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
|
||||||
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
// Selection anchor and focus are expected to be text nodes,
|
|
||||||
// so normalize them.
|
|
||||||
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const node = document.createTextNode('');
|
|
||||||
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
|
|
||||||
anchorNode = node;
|
|
||||||
anchorOffset = 0;
|
|
||||||
}
|
|
||||||
if (focusNode.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const node = document.createTextNode('');
|
|
||||||
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
|
|
||||||
focusNode = node;
|
|
||||||
focusOffset = 0;
|
|
||||||
}
|
|
||||||
visit(editor, el => {
|
|
||||||
if (el === anchorNode && el === focusNode) {
|
|
||||||
pos.start += anchorOffset;
|
|
||||||
pos.end += focusOffset;
|
|
||||||
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
|
|
||||||
return 'stop';
|
|
||||||
}
|
|
||||||
if (el === anchorNode) {
|
|
||||||
pos.start += anchorOffset;
|
|
||||||
if (!pos.dir) {
|
|
||||||
pos.dir = '->';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return 'stop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (el === focusNode) {
|
|
||||||
pos.end += focusOffset;
|
|
||||||
if (!pos.dir) {
|
|
||||||
pos.dir = '<-';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return 'stop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (el.nodeType === Node.TEXT_NODE) {
|
|
||||||
if (pos.dir != '->')
|
|
||||||
pos.start += el.nodeValue.length;
|
|
||||||
if (pos.dir != '<-')
|
|
||||||
pos.end += el.nodeValue.length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// collapse empty text nodes
|
|
||||||
editor.normalize();
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
function restore(pos) {
|
|
||||||
const s = getSelection();
|
|
||||||
let startNode, startOffset = 0;
|
|
||||||
let endNode, endOffset = 0;
|
|
||||||
if (!pos.dir)
|
|
||||||
pos.dir = '->';
|
|
||||||
if (pos.start < 0)
|
|
||||||
pos.start = 0;
|
|
||||||
if (pos.end < 0)
|
|
||||||
pos.end = 0;
|
|
||||||
// Flip start and end if the direction reversed
|
|
||||||
if (pos.dir == '<-') {
|
|
||||||
const { start, end } = pos;
|
|
||||||
pos.start = end;
|
|
||||||
pos.end = start;
|
|
||||||
}
|
|
||||||
let current = 0;
|
|
||||||
visit(editor, el => {
|
|
||||||
if (el.nodeType !== Node.TEXT_NODE)
|
|
||||||
return;
|
|
||||||
const len = (el.nodeValue || '').length;
|
|
||||||
if (current + len > pos.start) {
|
|
||||||
if (!startNode) {
|
|
||||||
startNode = el;
|
|
||||||
startOffset = pos.start - current;
|
|
||||||
}
|
|
||||||
if (current + len > pos.end) {
|
|
||||||
endNode = el;
|
|
||||||
endOffset = pos.end - current;
|
|
||||||
return 'stop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current += len;
|
|
||||||
});
|
|
||||||
if (!startNode)
|
|
||||||
startNode = editor, startOffset = editor.childNodes.length;
|
|
||||||
if (!endNode)
|
|
||||||
endNode = editor, endOffset = editor.childNodes.length;
|
|
||||||
// Flip back the selection
|
|
||||||
if (pos.dir == '<-') {
|
|
||||||
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
|
|
||||||
}
|
|
||||||
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
|
|
||||||
}
|
|
||||||
function beforeCursor() {
|
|
||||||
const s = getSelection();
|
|
||||||
const r0 = s.getRangeAt(0);
|
|
||||||
const r = document.createRange();
|
|
||||||
r.selectNodeContents(editor);
|
|
||||||
r.setEnd(r0.startContainer, r0.startOffset);
|
|
||||||
return r.toString();
|
|
||||||
}
|
|
||||||
function afterCursor() {
|
|
||||||
const s = getSelection();
|
|
||||||
const r0 = s.getRangeAt(0);
|
|
||||||
const r = document.createRange();
|
|
||||||
r.selectNodeContents(editor);
|
|
||||||
r.setStart(r0.endContainer, r0.endOffset);
|
|
||||||
return r.toString();
|
|
||||||
}
|
|
||||||
function handleNewLine(event) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
const before = beforeCursor();
|
|
||||||
const after = afterCursor();
|
|
||||||
let [padding] = findPadding(before);
|
|
||||||
let newLinePadding = padding;
|
|
||||||
// If last symbol is "{" ident new line
|
|
||||||
if (options.indentOn.test(before)) {
|
|
||||||
newLinePadding += options.tab;
|
|
||||||
}
|
|
||||||
// Preserve padding
|
|
||||||
if (newLinePadding.length > 0) {
|
|
||||||
preventDefault(event);
|
|
||||||
event.stopPropagation();
|
|
||||||
insert('\n' + newLinePadding);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
legacyNewLineFix(event);
|
|
||||||
}
|
|
||||||
// Place adjacent "}" on next line
|
|
||||||
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
|
|
||||||
const pos = save();
|
|
||||||
insert('\n' + padding);
|
|
||||||
restore(pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function legacyNewLineFix(event) {
|
|
||||||
// Firefox does not support plaintext-only mode
|
|
||||||
// and puts <div><br></div> on Enter. Let's help.
|
|
||||||
if (isLegacy && event.key === 'Enter') {
|
|
||||||
preventDefault(event);
|
|
||||||
event.stopPropagation();
|
|
||||||
if (afterCursor() == '') {
|
|
||||||
insert('\n ');
|
|
||||||
const pos = save();
|
|
||||||
pos.start = --pos.end;
|
|
||||||
restore(pos);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
insert('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleSelfClosingCharacters(event) {
|
|
||||||
const open = `([{'"`;
|
|
||||||
const close = `)]}'"`;
|
|
||||||
if (open.includes(event.key)) {
|
|
||||||
preventDefault(event);
|
|
||||||
const pos = save();
|
|
||||||
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
|
|
||||||
const text = event.key + wrapText + close[open.indexOf(event.key)];
|
|
||||||
insert(text);
|
|
||||||
pos.start++;
|
|
||||||
pos.end++;
|
|
||||||
restore(pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleTabCharacters(event) {
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
preventDefault(event);
|
|
||||||
if (event.shiftKey) {
|
|
||||||
const before = beforeCursor();
|
|
||||||
let [padding, start,] = findPadding(before);
|
|
||||||
if (padding.length > 0) {
|
|
||||||
const pos = save();
|
|
||||||
// Remove full length tab or just remaining padding
|
|
||||||
const len = Math.min(options.tab.length, padding.length);
|
|
||||||
restore({ start, end: start + len });
|
|
||||||
document.execCommand('delete');
|
|
||||||
pos.start -= len;
|
|
||||||
pos.end -= len;
|
|
||||||
restore(pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
insert(options.tab);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleUndoRedo(event) {
|
|
||||||
if (isUndo(event)) {
|
|
||||||
preventDefault(event);
|
|
||||||
at--;
|
|
||||||
const record = history[at];
|
|
||||||
if (record) {
|
|
||||||
editor.innerHTML = record.html;
|
|
||||||
restore(record.pos);
|
|
||||||
}
|
|
||||||
if (at < 0)
|
|
||||||
at = 0;
|
|
||||||
}
|
|
||||||
if (isRedo(event)) {
|
|
||||||
preventDefault(event);
|
|
||||||
at++;
|
|
||||||
const record = history[at];
|
|
||||||
if (record) {
|
|
||||||
editor.innerHTML = record.html;
|
|
||||||
restore(record.pos);
|
|
||||||
}
|
|
||||||
if (at >= history.length)
|
|
||||||
at--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function recordHistory() {
|
|
||||||
if (!focus)
|
|
||||||
return;
|
|
||||||
const html = editor.innerHTML;
|
|
||||||
const pos = save();
|
|
||||||
const lastRecord = history[at];
|
|
||||||
if (lastRecord) {
|
|
||||||
if (lastRecord.html === html
|
|
||||||
&& lastRecord.pos.start === pos.start
|
|
||||||
&& lastRecord.pos.end === pos.end)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
at++;
|
|
||||||
history[at] = { html, pos };
|
|
||||||
history.splice(at + 1);
|
|
||||||
const maxHistory = 300;
|
|
||||||
if (at > maxHistory) {
|
|
||||||
at = maxHistory;
|
|
||||||
history.splice(0, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handlePaste(event) {
|
|
||||||
preventDefault(event);
|
|
||||||
const text = (event.originalEvent || event)
|
|
||||||
.clipboardData
|
|
||||||
.getData('text/plain')
|
|
||||||
.replace(/\r\n?/g, '\n');
|
|
||||||
const pos = save();
|
|
||||||
insert(text);
|
|
||||||
highlight(editor);
|
|
||||||
restore({
|
|
||||||
start: Math.min(pos.start, pos.end) + text.length,
|
|
||||||
end: Math.min(pos.start, pos.end) + text.length,
|
|
||||||
dir: '<-',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function handleCut(event) {
|
|
||||||
const pos = save();
|
|
||||||
const selection = getSelection();
|
|
||||||
const originalEvent = event.originalEvent ?? event;
|
|
||||||
originalEvent.clipboardData.setData("text/plain", selection.toString());
|
|
||||||
document.execCommand('delete');
|
|
||||||
highlight(editor);
|
|
||||||
restore({
|
|
||||||
start: pos.start,
|
|
||||||
end: pos.start,
|
|
||||||
dir: '->',
|
|
||||||
});
|
|
||||||
preventDefault(event);
|
|
||||||
}
|
|
||||||
function visit(editor, visitor) {
|
|
||||||
const queue = [];
|
|
||||||
if (editor.firstChild)
|
|
||||||
queue.push(editor.firstChild);
|
|
||||||
let el = queue.pop();
|
|
||||||
while (el) {
|
|
||||||
if (visitor(el) === 'stop')
|
|
||||||
break;
|
|
||||||
if (el.nextSibling)
|
|
||||||
queue.push(el.nextSibling);
|
|
||||||
if (el.firstChild)
|
|
||||||
queue.push(el.firstChild);
|
|
||||||
el = queue.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function isCtrl(event) {
|
|
||||||
return event.metaKey || event.ctrlKey;
|
|
||||||
}
|
|
||||||
function isUndo(event) {
|
|
||||||
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
|
|
||||||
}
|
|
||||||
function isRedo(event) {
|
|
||||||
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
|
|
||||||
}
|
|
||||||
function isCopy(event) {
|
|
||||||
return isCtrl(event) && getKeyCode(event) === 'C';
|
|
||||||
}
|
|
||||||
function getKeyCode(event) {
|
|
||||||
let key = event.key || event.keyCode || event.which;
|
|
||||||
if (!key)
|
|
||||||
return undefined;
|
|
||||||
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
|
|
||||||
}
|
|
||||||
function insert(text) {
|
|
||||||
text = text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
document.execCommand('insertHTML', false, text);
|
|
||||||
}
|
|
||||||
function debounce(cb, wait) {
|
|
||||||
let timeout = 0;
|
|
||||||
return (...args) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = window.setTimeout(() => cb(...args), wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function findPadding(text) {
|
|
||||||
// Find beginning of previous line.
|
|
||||||
let i = text.length - 1;
|
|
||||||
while (i >= 0 && text[i] !== '\n')
|
|
||||||
i--;
|
|
||||||
i++;
|
|
||||||
// Find padding of the line.
|
|
||||||
let j = i;
|
|
||||||
while (j < text.length && /[ \t]/.test(text[j]))
|
|
||||||
j++;
|
|
||||||
return [text.substring(i, j) || '', i, j];
|
|
||||||
}
|
|
||||||
function toString() {
|
|
||||||
return editor.textContent || '';
|
|
||||||
}
|
|
||||||
function preventDefault(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
function getSelection() {
|
|
||||||
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
|
|
||||||
return editor.parentNode.getSelection();
|
|
||||||
}
|
|
||||||
return window.getSelection();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
updateOptions(newOptions) {
|
|
||||||
Object.assign(options, newOptions);
|
|
||||||
},
|
|
||||||
updateCode(code) {
|
|
||||||
editor.textContent = code;
|
|
||||||
highlight(editor);
|
|
||||||
if (callback)
|
|
||||||
callback(code);
|
|
||||||
},
|
|
||||||
onUpdate(cb) {
|
|
||||||
callback = cb;
|
|
||||||
},
|
|
||||||
toString,
|
|
||||||
save,
|
|
||||||
restore,
|
|
||||||
recordHistory,
|
|
||||||
destroy() {
|
|
||||||
for (let [type, fn] of listeners) {
|
|
||||||
editor.removeEventListener(type, fn);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser global shim: surface CodeJar on window so non-module
|
|
||||||
// <script> tags can call it.
|
|
||||||
window.CodeJar = CodeJar;
|
|
||||||
1
l4d2web/l4d2web/static/vendor/prism.css
vendored
1
l4d2web/l4d2web/static/vendor/prism.css
vendored
|
|
@ -1 +0,0 @@
|
||||||
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
|
|
||||||
4
l4d2web/l4d2web/static/vendor/prism.js
vendored
4
l4d2web/l4d2web/static/vendor/prism.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,16 +0,0 @@
|
||||||
{# Editor asset bundle — include on any page that mounts a
|
|
||||||
<textarea data-editor-language>. Order matters: prism + codejar load
|
|
||||||
first, then the srccfg grammar registers itself on window.Prism, then
|
|
||||||
editor.js scans the DOM and mounts.
|
|
||||||
|
|
||||||
prism.css is intentionally NOT loaded — its base `code[class*=language-]`
|
|
||||||
rule has specificity (0,1,1) which beats our `.editor-code` (0,1,0)
|
|
||||||
and forces color:#000 + background:transparent, which renders black
|
|
||||||
text on the dark-mode page background. We override every Prism token
|
|
||||||
class we use in editor.css with --color-* tokens that follow
|
|
||||||
prefers-color-scheme, so the default Prism theme adds nothing useful. #}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
|
|
||||||
<script src="{{ url_for('static', filename='vendor/prism.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='vendor/codejar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/srccfg-grammar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/editor.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
|
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Save blueprint</button>
|
<button type="submit">Save blueprint</button>
|
||||||
|
|
@ -92,5 +92,4 @@
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
||||||
{% include '_editor_assets.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Name <input name="name" required autofocus></label>
|
<label>Name <input name="name" required></label>
|
||||||
{# Arguments, config, and overlay assignments are edited on the
|
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
|
||||||
blueprint detail page where the srccfg editor + overlay picker
|
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
|
||||||
live. Keeping the create modal name-only avoids the conflict
|
|
||||||
where modal textareas can't host the editor cleanly. #}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Bash script
|
<label>Bash script
|
||||||
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
|
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
||||||
{% if not latest_build_is_running %}
|
{% if not latest_build_is_running %}
|
||||||
|
|
@ -175,16 +175,7 @@
|
||||||
<div class="files-editor-text">
|
<div class="files-editor-text">
|
||||||
<label class="files-editor-field">
|
<label class="files-editor-field">
|
||||||
<span class="files-field-label">Content</span>
|
<span class="files-field-label">Content</span>
|
||||||
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
|
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
|
||||||
</label>
|
|
||||||
<label class="files-editor-field files-editor-language-field">
|
|
||||||
<span class="files-field-label">Language</span>
|
|
||||||
<select class="files-editor-language editor-language-select">
|
|
||||||
<option value="auto" selected>Auto (from filename)</option>
|
|
||||||
<option value="srccfg">Source config (.cfg)</option>
|
|
||||||
<option value="bash">Bash (.sh)</option>
|
|
||||||
<option value="plain">Plain text</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="files-editor-meta muted">
|
<div class="files-editor-meta muted">
|
||||||
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
|
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
|
||||||
|
|
@ -282,11 +273,4 @@
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Only include the ~30 KB of editor assets on pages that actually mount
|
|
||||||
an editor: script-type overlays (bash editor) and files-type overlays
|
|
||||||
that the current user can edit (the files-editor modal). Workshop
|
|
||||||
overlays and read-only pages skip the include entirely. #}
|
|
||||||
{% if overlay.type == 'script' or files_can_edit %}
|
|
||||||
{% include '_editor_assets.html' %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
"""End-to-end test for the textarea code editor.
|
|
||||||
|
|
||||||
Logs in as the seed user, navigates to the blueprint detail page, types
|
|
||||||
`sv_che` into the editor, asserts the autocomplete popup appears with
|
|
||||||
`sv_cheats`, accepts via Tab, and asserts the underlying textarea now
|
|
||||||
contains `sv_cheats`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from playwright.sync_api import expect, sync_playwright
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
|
||||||
def test_editor_autocomplete_inserts_cvar(live_server) -> None:
|
|
||||||
base = live_server["base_url"]
|
|
||||||
blueprint_id = live_server["blueprint_id"]
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch()
|
|
||||||
ctx = browser.new_context()
|
|
||||||
page = ctx.new_page()
|
|
||||||
|
|
||||||
# Log in.
|
|
||||||
page.goto(f"{base}/login")
|
|
||||||
page.fill('input[name="username"]', "alice")
|
|
||||||
page.fill('input[name="password"]', "secret")
|
|
||||||
page.click('button[type="submit"]')
|
|
||||||
expect(page).to_have_url(f"{base}/dashboard", timeout=5000)
|
|
||||||
|
|
||||||
# Navigate to the seeded blueprint.
|
|
||||||
page.goto(f"{base}/blueprints/{blueprint_id}")
|
|
||||||
|
|
||||||
# Editor mounts on DOMContentLoaded; the contenteditable replaces
|
|
||||||
# the textarea visually. Wait for it.
|
|
||||||
editor = page.locator(".editor-code").first
|
|
||||||
expect(editor).to_be_visible(timeout=5000)
|
|
||||||
|
|
||||||
# Focus the editor and type a cvar prefix.
|
|
||||||
editor.click()
|
|
||||||
page.keyboard.type("sv_che")
|
|
||||||
|
|
||||||
# The popup should appear and contain sv_cheats.
|
|
||||||
popup = page.locator(".editor-popup")
|
|
||||||
expect(popup).to_be_visible(timeout=2000)
|
|
||||||
expect(popup).to_contain_text("sv_cheats")
|
|
||||||
|
|
||||||
# Accept via Tab. Verifies Task 9's capture-phase keydown fix:
|
|
||||||
# CodeJar would otherwise consume Tab and insert 2 spaces
|
|
||||||
# before our popup handler ran.
|
|
||||||
page.keyboard.press("Tab")
|
|
||||||
|
|
||||||
# The hidden textarea (form field) must now contain the cvar.
|
|
||||||
textarea_value = page.evaluate(
|
|
||||||
"() => document.querySelector('textarea[name=config]').value"
|
|
||||||
)
|
|
||||||
assert "sv_cheats" in textarea_value
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
@ -253,62 +253,6 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
|
||||||
assert update.headers["Location"] == "/blueprints/1"
|
assert update.headers["Location"] == "/blueprints/1"
|
||||||
|
|
||||||
|
|
||||||
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
|
|
||||||
with session_scope() as session:
|
|
||||||
user = session.scalar(select(User).where(User.username == "alice"))
|
|
||||||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
||||||
session.add(blueprint)
|
|
||||||
session.flush()
|
|
||||||
blueprint_id = blueprint.id
|
|
||||||
|
|
||||||
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
# Editor opts the textarea in via a data-attribute.
|
|
||||||
assert 'data-editor-language="srccfg"' in body
|
|
||||||
# All editor assets are referenced. prism.css intentionally not loaded
|
|
||||||
# — its default theme's `code[class*=language-]` selector beats our
|
|
||||||
# `.editor-code` background/color rules at higher specificity and
|
|
||||||
# breaks dark mode. We override every Prism token class we use in
|
|
||||||
# editor.css with theme-aware --color-* tokens instead.
|
|
||||||
assert "static/vendor/prism.js" in body
|
|
||||||
assert "static/vendor/codejar.js" in body
|
|
||||||
assert "static/js/srccfg-grammar.js" in body
|
|
||||||
assert "static/js/editor.js" in body
|
|
||||||
assert "static/css/editor.css" in body
|
|
||||||
assert "static/vendor/prism.css" not in body
|
|
||||||
# Scripts are nonce'd (CSP regression guard).
|
|
||||||
assert 'nonce="' in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
|
|
||||||
# The editor is a visual layer; the form POST contract must be
|
|
||||||
# unaffected. This guards against accidentally renaming `config` or
|
|
||||||
# dropping it from form serialization.
|
|
||||||
with session_scope() as session:
|
|
||||||
user = session.scalar(select(User).where(User.username == "alice"))
|
|
||||||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
||||||
session.add(blueprint)
|
|
||||||
session.flush()
|
|
||||||
blueprint_id = blueprint.id
|
|
||||||
|
|
||||||
update = user_client.post(
|
|
||||||
f"/blueprints/{blueprint_id}",
|
|
||||||
data={
|
|
||||||
"name": "bp",
|
|
||||||
"arguments": "",
|
|
||||||
"config": "sv_cheats 1\nmp_gamemode coop",
|
|
||||||
"overlay_ids": [],
|
|
||||||
},
|
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
|
||||||
)
|
|
||||||
assert update.status_code == 302
|
|
||||||
|
|
||||||
with session_scope() as session:
|
|
||||||
bp = session.get(Blueprint, blueprint_id)
|
|
||||||
assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
|
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
|
||||||
|
|
|
||||||
|
|
@ -277,25 +277,3 @@ def test_permissions_admin_can_edit_any(app, alice_id, admin_id) -> None:
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
||||||
assert overlay.script == "echo admin"
|
assert overlay.script == "echo admin"
|
||||||
|
|
||||||
|
|
||||||
def test_script_overlay_detail_renders_bash_editor(app, alice_id) -> None:
|
|
||||||
overlay_id = _create_script_overlay(app, alice_id, name="bash-editor-test")
|
|
||||||
client = _client_for(app, alice_id)
|
|
||||||
|
|
||||||
response = client.get(f"/overlays/{overlay_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
# Editor opts the textarea in via a data-attribute.
|
|
||||||
assert 'data-editor-language="bash"' in body
|
|
||||||
# All editor assets are referenced. prism.css intentionally not loaded
|
|
||||||
# — see _editor_assets.html for the rationale (specificity conflict
|
|
||||||
# with our dark-mode tokens).
|
|
||||||
assert "static/vendor/prism.js" in body
|
|
||||||
assert "static/vendor/codejar.js" in body
|
|
||||||
assert "static/js/srccfg-grammar.js" in body
|
|
||||||
assert "static/js/editor.js" in body
|
|
||||||
assert "static/css/editor.css" in body
|
|
||||||
assert "static/vendor/prism.css" not in body
|
|
||||||
# Scripts are nonce'd (CSP regression guard).
|
|
||||||
assert 'nonce="' in body
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue