Files
astrolabe/docs/architecture/editor-augmentation-demo.html
T

854 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Editor augmentation — Monaco interactivity sandbox</title>
<!--
Throwaway sandbox (companion to docs/architecture/08). NOT a product feature
and not maintained — Monaco is loaded from CDN so this file touches nothing
in the Astrolabe build. Open it directly in a browser (needs internet for the
CDN). It demonstrates, against a live Vega-Lite spec, every editor-augmentation
surface discussed for spec editing:
1. Code actions (the lightbulb / ⌘. ) — wrap the focused view in
layer / hconcat / vconcat; change a mark, type, or field value.
2. Context-menu + F1 command-palette actions — the same transforms.
3. CodeLens — inline " add view / add encoding" affordances.
4. Dataset-aware completion — column names + types the JSON schema can't know.
5. Hover — inferred type + sample values for a bound column.
6. Inlay hints — ghost type annotations beside each field.
7. Diagnostics → quick fix — unknown field gets a squiggle and a "did you
mean…" fix (open the spec with a deliberate typo to see it on load).
Sub-tree scoping: select a child view's JSON and the wrap targets just that
selection; with no selection it wraps the whole document. (In the app the
cursor's enclosing node is found automatically via jsonc-parser; here we keep
the sandbox dependency-free so it runs straight off the filesystem.)
This is a superset of what shipped: the unknown-field diagnostic + quick fix (7)
and the " add encoding" CodeLens (3) are options explored here but deliberately
NOT shipped — the shipped product carries no field diagnostic (it would
false-positive on derived/data-dependent fields). See architecture 08 §5.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #0b0c0e;
--panel: #14161a;
--panel-2: #1b1e24;
--border: #2a2e36;
--text: #e6e8ec;
--muted: #9aa3af;
--accent: #6ea8fe;
--accent-soft: #243245;
--good: #5ad19a;
--warn: #e0b341;
}
body.light {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--border: #d8dce2;
--text: #161a1f;
--muted: #5b6470;
--accent: #2f6fed;
--accent-soft: #e4ecfb;
--good: #128a5b;
--warn: #9a6b00;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: "IBM Plex Sans", system-ui, sans-serif;
display: grid;
grid-template-rows: auto 1fr;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 {
font-size: 15px;
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
}
header .sub {
color: var(--muted);
font-size: 12.5px;
}
header .spacer {
flex: 1;
}
.control {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
}
button,
select {
font-family: inherit;
font-size: 12.5px;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
main {
display: grid;
grid-template-columns: 1.55fr 1fr;
min-height: 0;
}
#editor {
min-width: 0;
border-right: 1px solid var(--border);
}
aside {
overflow-y: auto;
padding: 14px 16px 40px;
background: var(--panel);
}
aside h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin: 18px 0 8px;
}
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
margin-bottom: 10px;
}
.card .name {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.card .name .pill {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
font-weight: 500;
color: var(--accent);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 6px;
}
.card p {
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
margin: 7px 0 9px;
}
.card p code,
.how code {
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
.card .try {
font-size: 12px;
padding: 4px 9px;
}
.intro {
font-size: 12.5px;
line-height: 1.55;
color: var(--muted);
margin: 4px 0 6px;
}
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--warn);
color: var(--text);
padding: 8px 14px;
border-radius: 8px;
font-size: 12.5px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
</head>
<body>
<header>
<h1>Editor augmentation</h1>
<span class="sub">Monaco interactivity sandbox · companion to architecture 08</span>
<span class="spacer"></span>
<label class="control">
<input type="checkbox" id="inlay" checked />
Inlay hints
</label>
<label class="control">
Theme
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<button id="reset">Reset spec</button>
</header>
<main>
<div id="editor"></div>
<aside>
<p class="intro">
A live Vega-Lite spec bound to a fake <code>weather</code> dataset
(<code>date</code>, <code>precipitation</code>, <code>temp_max</code>,
<code>temp_min</code>, <code>wind</code>, <code>weather</code>). Move the
cursor around and try each surface — every action edits the real document and
is undoable with <code>⌘/Ctrl+Z</code>.
</p>
<h2>Refactor & transform</h2>
<div class="card">
<div class="name"><span class="pill">⌘.</span> Lightbulb code actions</div>
<p>
The contextual refactor menu. Open it in the view and you'll see
<em>Wrap in layer / hconcat / vconcat</em>, plus value swaps when you're on a
<code>mark</code>, <code>type</code>, or <code>field</code> line. Select a
child view's JSON first to wrap just that part; with no selection it wraps the
whole document. This is the position-aware "give me ideas" surface.
</p>
<button class="try" data-act="quickfix">Open at cursor</button>
</div>
<div class="card">
<div class="name"><span class="pill">right-click / F1</span> Menu actions</div>
<p>
The same transforms as durable menu items — a discoverable home with the
lightbulb as the accelerator. Right-click the editor, or press
<code>F1</code> and type "wrap".
</p>
<button class="try" data-act="palette">Command palette</button>
</div>
<div class="card">
<div class="name"><span class="pill">inline</span> CodeLens</div>
<p>
Clickable affordances rendered above a line: <code> add layer</code> /
<code> add color encoding</code> over <code>"mark"</code>, and
<code> add view</code> over a composition array. Look just above the
<code>"mark"</code> line.
</p>
</div>
<h2>Beyond the schema</h2>
<div class="card">
<div class="name"><span class="pill">⌃Space</span> Dataset-aware completion</div>
<p>
In a <code>"field"</code> value, suggestions are the dataset's real columns
with their inferred types — something the Vega-Lite schema can't know. Also
augments <code>type</code>, <code>mark</code>, and <code>aggregate</code>
values. Click inside a field's quotes and press <code>⌃Space</code>.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">hover</span> Field hover</div>
<p>
Hover a column name to see its inferred type and sample values pulled from
the bound dataset (merged with, not replacing, the schema's own hover).
</p>
</div>
<div class="card">
<div class="name"><span class="pill">ghost</span> Inlay hints</div>
<p>
Each <code>"field"</code> gets a faint type annotation beside it — annotation
without touching the text. Toggle it from the header.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">squiggle</span> Diagnostics → quick fix</div>
<p>
A field not in the dataset gets a warning squiggle and a "Change to …" quick
fix. The starter spec ships one typo (<code>"wnd"</code>) so you can see it
immediately — open the lightbulb on that line.
</p>
</div>
<h2>Editor basics (for reference)</h2>
<div class="card">
<div class="name"><span class="pill">⇧⌥F</span> Format & misc</div>
<p>Format the document, then notice folding, multi-cursor, and the minimap all come free with Monaco.</p>
<button class="try" data-act="format">Format document</button>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs/loader.js"></script>
<script>
// ---- Monaco CDN worker proxy (standard self-hosting-from-CDN snippet) ----
const CDN = "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/";
self.MonacoEnvironment = {
getWorkerUrl: function () {
return (
"data:text/javascript;charset=utf-8," +
encodeURIComponent(
"self.MonacoEnvironment={baseUrl:'" +
CDN +
"'};importScripts('" +
CDN +
"vs/base/worker/workerMain.js');",
)
);
},
};
require.config({ paths: { vs: CDN + "vs" } });
// -------------------------- the fake dataset --------------------------
const COLUMNS = {
date: { type: "temporal", samples: ["2012-01-01", "2012-01-02", "2012-01-03"] },
precipitation: { type: "quantitative", samples: [0.0, 10.9, 0.8] },
temp_max: { type: "quantitative", samples: [12.8, 10.6, 11.7] },
temp_min: { type: "quantitative", samples: [5.0, 2.8, 7.2] },
wind: { type: "quantitative", samples: [4.7, 4.5, 2.3] },
weather: { type: "nominal", samples: ["drizzle", "rain", "sun"] },
};
const COLUMN_NAMES = Object.keys(COLUMNS);
const MARKS = ["bar", "line", "point", "area", "tick", "circle", "rect"];
const VL_TYPES = ["quantitative", "nominal", "ordinal", "temporal"];
const AGGREGATES = ["mean", "sum", "median", "min", "max", "count"];
const STARTER = JSON.stringify(
{
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
data: { name: "weather" },
mark: "bar",
encoding: {
x: { field: "date", type: "temporal", timeUnit: "month" },
y: { field: "precipitation", type: "quantitative", aggregate: "mean" },
color: { field: "weather", type: "nominal" },
size: { field: "wnd", type: "quantitative" }, // deliberate typo → squiggle
},
},
null,
2,
);
require(["vs/editor/editor.main"], function () {
const editor = monaco.editor.create(document.getElementById("editor"), {
value: STARTER,
language: "json",
theme: "vs-dark",
automaticLayout: true,
fontFamily: "'IBM Plex Mono', ui-monospace, Menlo, monospace",
fontSize: 13,
tabSize: 2,
scrollBeyondLastLine: false,
minimap: { enabled: true },
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
inlayHints: { enabled: "on" },
});
const model = editor.getModel();
// ---- Vega-Lite schema (same approach as the app's monaco-schema.ts:
// register the schema explicitly, no network schema-request service) so the
// schema's own validation / completion / hover work alongside the custom
// providers below. The spec's $schema URI is matched by fileMatch:['*'].
fetch("https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite-schema.json")
.then((r) => r.json())
.then((schema) => {
addMarkdownDescriptions(schema); // Monaco renders rich hovers only from markdownDescription
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: false,
schemas: [
{ uri: "https://vega.github.io/schema/vega-lite/v6.json", fileMatch: ["*"], schema },
],
});
})
.catch(() => toast("Couldn't load the Vega-Lite schema from CDN (offline?)."));
// ===================== helpers =====================
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1800);
}
// Copy each `description` to `markdownDescription` so Monaco hovers render
// the schema docs as markdown (plain `description` hovers as flat text).
function addMarkdownDescriptions(node) {
if (Array.isArray(node)) return node.forEach(addMarkdownDescriptions);
if (node && typeof node === "object") {
if (typeof node.description === "string" && node.markdownDescription === undefined)
node.markdownDescription = node.description;
for (const k of Object.keys(node)) addMarkdownDescriptions(node[k]);
}
}
function reindent(text, baseCol) {
if (!baseCol) return text;
const pad = " ".repeat(baseCol);
return text
.split("\n")
.map((l, i) => (i === 0 ? l : pad + l))
.join("\n");
}
// Move the unit-level props into the wrapper; keep shared props on top.
function wrapSpec(spec, kind) {
const top = {};
const inner = {};
const sharedForLayer = [
"$schema", "data", "width", "height", "title", "name", "description", "config", "resolve",
];
const sharedForConcat = ["$schema", "data", "title", "name", "description", "config"];
const shared = kind === "layer" ? sharedForLayer : sharedForConcat;
for (const k of Object.keys(spec)) {
if (shared.includes(k)) top[k] = spec[k];
else inner[k] = spec[k];
}
const placeholder = { mark: "point", encoding: {} };
top[kind] = [inner, placeholder];
return top;
}
// Compute (don't apply) the wrap edit. Targets the selection when there is
// one (so you can wrap a single child view), else the whole document.
// Returns null when the target text isn't a JSON object.
function planWrap(selection, kind) {
let range, srcText, baseCol;
if (selection && !selection.isEmpty()) {
range = selection;
srcText = model.getValueInRange(range);
baseCol = selection.startColumn - 1;
} else {
range = model.getFullModelRange();
srcText = model.getValue();
baseCol = 0;
}
let obj;
try {
obj = JSON.parse(srcText);
} catch {
return null;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
return { range, text: reindent(JSON.stringify(wrapSpec(obj, kind), null, 2), baseCol) };
}
function applyWrap(kind) {
const plan = planWrap(editor.getSelection(), kind);
if (!plan) {
toast("Fix the JSON syntax first, then try again.");
return;
}
editor.pushUndoStop();
editor.executeEdits("wrap", [{ range: plan.range, text: plan.text }]);
editor.pushUndoStop();
editor.focus();
}
// The {range,value} of a "key": "value" string on a given line.
function stringValueRange(lineNumber, key) {
const line = model.getLineContent(lineNumber);
const re = new RegExp('("' + key + '"\\s*:\\s*")([^"]*)(")');
const m = re.exec(line);
if (!m) return null;
const startCol = m.index + m[1].length + 1; // 1-based col of value start
const endCol = startCol + m[2].length;
return {
range: new monaco.Range(lineNumber, startCol, lineNumber, endCol),
value: m[2],
};
}
function levenshtein(a, b) {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
return dp[a.length][b.length];
}
function closestColumn(name) {
let best = null;
let bestD = Infinity;
for (const c of COLUMN_NAMES) {
const d = levenshtein(name, c);
if (d < bestD) {
bestD = d;
best = c;
}
}
return bestD <= 3 ? best : null;
}
// ===================== diagnostics: unknown field =====================
function refreshMarkers() {
const markers = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const found = stringValueRange(ln, "field");
if (found && !COLUMN_NAMES.includes(found.value)) {
const suggestion = closestColumn(found.value);
markers.push({
severity: monaco.MarkerSeverity.Warning,
message:
'"' + found.value + '" is not a column in the weather dataset' +
(suggestion ? '. Did you mean "' + suggestion + '"?' : "."),
startLineNumber: found.range.startLineNumber,
startColumn: found.range.startColumn,
endLineNumber: found.range.endLineNumber,
endColumn: found.range.endColumn,
code: "unknown-field",
});
}
}
monaco.editor.setModelMarkers(model, "astrolabe", markers);
}
editor.onDidChangeModelContent(refreshMarkers);
refreshMarkers();
// ===================== 1. code action provider =====================
monaco.languages.registerCodeActionProvider("json", {
provideCodeActions(model, range, context) {
const actions = [];
const pos = range.getStartPosition();
const line = model.getLineContent(pos.lineNumber);
// Wrap actions — available anywhere the document parses to an object.
for (const [kind, label] of [
["layer", "Wrap focused view in a layer"],
["hconcat", "Wrap focused view in horizontal concat"],
["vconcat", "Wrap focused view in vertical concat"],
]) {
const plan = planWrap(range, kind);
if (plan) {
actions.push({
title: label,
kind: "refactor.rewrite",
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: plan.range, text: plan.text },
},
],
},
});
}
}
const replaceValue = (title, key, value, kindStr, preferred) => {
const v = stringValueRange(pos.lineNumber, key);
if (!v || v.value === value) return;
actions.push({
title,
kind: kindStr || "refactor.rewrite",
isPreferred: !!preferred,
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: v.range, text: value },
},
],
},
});
};
if (/"mark"\s*:/.test(line))
MARKS.forEach((mk) => replaceValue("Change mark to “" + mk + "”", "mark", mk));
if (/"type"\s*:/.test(line))
VL_TYPES.forEach((t) => replaceValue("Set type: " + t, "type", t));
if (/"field"\s*:/.test(line))
COLUMN_NAMES.forEach((c) => replaceValue("Change field to “" + c + "”", "field", c));
// Quick fix tied to the unknown-field markers.
for (const m of context.markers) {
if (m.code !== "unknown-field") continue;
const bad = model.getValueInRange(m);
const fix = closestColumn(bad);
if (!fix) continue;
actions.push({
title: 'Change to "' + fix + '"',
kind: "quickfix",
isPreferred: true,
diagnostics: [m],
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: {
range: new monaco.Range(
m.startLineNumber,
m.startColumn,
m.endLineNumber,
m.endColumn,
),
text: fix,
},
},
],
},
});
}
return { actions, dispose() {} };
},
});
// ===================== 2. context-menu / F1 actions =====================
editor.addAction({
id: "demo.wrap.layer",
label: "Wrap Focused View in a Layer",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 1,
run: () => applyWrap("layer"),
});
editor.addAction({
id: "demo.wrap.hconcat",
label: "Wrap Focused View in Horizontal Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 2,
run: () => applyWrap("hconcat"),
});
editor.addAction({
id: "demo.wrap.vconcat",
label: "Wrap Focused View in Vertical Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 3,
run: () => applyWrap("vconcat"),
});
editor.addAction({
id: "demo.add.color",
label: "Add Color Encoding",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 4,
run: () => addColorEncoding(),
});
function addColorEncoding() {
let spec;
try {
spec = JSON.parse(model.getValue());
} catch {
toast("Fix the JSON syntax first, then try again.");
return;
}
spec.encoding = spec.encoding || {};
spec.encoding.color = { field: "weather", type: "nominal" };
editor.pushUndoStop();
editor.executeEdits("add-color", [
{ range: model.getFullModelRange(), text: JSON.stringify(spec, null, 2) },
]);
editor.pushUndoStop();
}
// ===================== 3. CodeLens =====================
const lensWrapLayer = editor.addCommand(0, () => applyWrap("layer"));
const lensAddColor = editor.addCommand(0, () => addColorEncoding());
const lensAddView = editor.addCommand(0, (_ctx, kind) => applyWrap(kind || "hconcat"));
monaco.languages.registerCodeLensProvider("json", {
provideCodeLenses(model) {
const lenses = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const line = model.getLineContent(ln);
if (/"mark"\s*:/.test(line)) {
const range = new monaco.Range(ln, 1, ln, 1);
lenses.push({ range, command: { id: lensWrapLayer, title: " add layer" } });
lenses.push({ range, command: { id: lensAddColor, title: " add color encoding" } });
}
if (/"(layer|hconcat|vconcat|concat)"\s*:\s*\[/.test(line)) {
lenses.push({
range: new monaco.Range(ln, 1, ln, 1),
command: { id: lensAddView, title: " add view", arguments: ["hconcat"] },
});
}
}
return { lenses, dispose() {} };
},
resolveCodeLens(_model, lens) {
return lens;
},
});
// ===================== 4. completion provider =====================
monaco.languages.registerCompletionItemProvider("json", {
triggerCharacters: ['"', ":", " "],
provideCompletionItems(model, position) {
const before = model
.getValueInRange(new monaco.Range(position.lineNumber, 1, position.lineNumber, position.column));
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
const md = (s) => ({ value: s });
let items = [];
if (/"field"\s*:\s*"[^"]*$/.test(before)) {
items = COLUMN_NAMES.map((c) => ({
label: c,
kind: monaco.languages.CompletionItemKind.Field,
detail: COLUMNS[c].type + " · from dataset “weather”",
documentation: md("Sample: " + COLUMNS[c].samples.join(", ")),
insertText: c,
range,
}));
} else if (/"type"\s*:\s*"[^"]*$/.test(before)) {
items = VL_TYPES.map((t) => ({
label: t,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: t,
range,
}));
} else if (/"mark"\s*:\s*"[^"]*$/.test(before)) {
items = MARKS.map((mk) => ({
label: mk,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: mk,
range,
}));
} else if (/"aggregate"\s*:\s*"[^"]*$/.test(before)) {
items = AGGREGATES.map((a) => ({
label: a,
kind: monaco.languages.CompletionItemKind.Function,
insertText: a,
range,
}));
}
return { suggestions: items };
},
});
// ===================== 5. hover provider =====================
monaco.languages.registerHoverProvider("json", {
provideHover(model, position) {
const w = model.getWordAtPosition(position);
if (!w) return null;
const name = w.word;
if (COLUMNS[name]) {
const col = COLUMNS[name];
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [
{ value: "**" + name + "** · `" + col.type + "`" },
{ value: "Sample values: " + col.samples.join(", ") },
{ value: "_from the bound dataset “weather”_" },
],
};
}
if (MARKS.includes(name)) {
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [{ value: "**mark · " + name + "**" }, { value: "A Vega-Lite mark type." }],
};
}
return null;
},
});
// ===================== 6. inlay hints =====================
monaco.languages.registerInlayHintsProvider("json", {
provideInlayHints(model, range) {
const hints = [];
for (let ln = range.startLineNumber; ln <= range.endLineNumber; ln++) {
const v = stringValueRange(ln, "field");
if (v && COLUMNS[v.value]) {
hints.push({
position: { lineNumber: ln, column: v.range.endColumn + 1 },
label: ": " + COLUMNS[v.value].type,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
}
return { hints, dispose() {} };
},
});
// ===================== UI wiring =====================
document.getElementById("theme").addEventListener("change", (e) => {
const dark = e.target.value === "dark";
monaco.editor.setTheme(dark ? "vs-dark" : "vs");
document.body.classList.toggle("light", !dark);
});
document.getElementById("inlay").addEventListener("change", (e) => {
editor.updateOptions({ inlayHints: { enabled: e.target.checked ? "on" : "off" } });
});
document.getElementById("reset").addEventListener("click", () => {
model.setValue(STARTER);
refreshMarkers();
});
document.querySelectorAll(".try").forEach((btn) => {
btn.addEventListener("click", () => {
editor.focus();
const act = btn.dataset.act;
if (act === "palette") editor.trigger("demo", "editor.action.quickCommand", null);
else if (act === "quickfix") {
// park the cursor on the "mark" line so the lightbulb has something to show
const text = model.getValue();
const idx = text.split("\n").findIndex((l) => /"mark"\s*:/.test(l));
if (idx >= 0) editor.setPosition({ lineNumber: idx + 1, column: 5 });
editor.trigger("demo", "editor.action.quickFix", null);
} else if (act === "format") editor.getAction("editor.action.formatDocument").run();
});
});
});
</script>
</body>
</html>