codemirror

This commit is contained in:
2026-06-12 12:12:49 -04:00
parent c39c50b9a0
commit 0adb980cf7
7 changed files with 869 additions and 236 deletions

View File

@@ -11,15 +11,13 @@
var conflictApply = document.querySelector("[data-conflict-apply]");
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
var conflictResolution = document.querySelector("[data-conflict-resolution]");
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
var conflictResolutionMount = document.querySelector("[data-conflict-codemirror-mount]");
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
var monacoContainer = document.querySelector("[data-monaco-mount]");
var monacoLoaded = false;
var monacoReadyPromise = null;
var monacoDarkTheme = "cairnquire-dark";
var monacoLightTheme = "cairnquire-light";
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
var codeMirrorReadyPromise = null;
var codeMirrorModules = null;
if (!form || !textarea || statusNodes.length === 0) {
return;
@@ -36,6 +34,9 @@
var conflictDiffView = null;
var resolutionEditor = null;
var suppressResolutionEvents = false;
var doneLink = document.querySelector("[data-done-link]");
var isSaving = false;
var suppressBeforeUnload = false;
function setStatus(nextStatus) {
statusNodes.forEach(function (node) {
@@ -223,7 +224,7 @@
var value = getEditorValue();
if (value === lastSavedValue) {
setStatus("Saved");
return;
return "unchanged";
}
isSaving = true;
@@ -233,7 +234,7 @@
if (!window.MDHubSync) {
isSaving = false;
setStatus("Queued");
return;
return "queued";
}
try {
@@ -249,16 +250,17 @@
}
setStatus("Saved");
isSaving = false;
return;
return "saved";
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
isSaving = false;
return;
return "conflict";
}
setStatus("Queued");
return "queued";
} catch (err) {
setStatus("Queued");
throw err;
@@ -328,138 +330,236 @@
}, 2000);
}
function loadMonaco() {
if (monacoReadyPromise) {
return monacoReadyPromise;
}
if (monacoLoaded && window.monaco) {
monacoReadyPromise = Promise.resolve();
return monacoReadyPromise;
}
monacoLoaded = true;
function doneHref() {
return doneLink ? doneLink.getAttribute("href") : "/docs/" + path.replace(/\.md$/, "");
}
function clearScheduledSave() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
function leaveEditorNow() {
clearScheduledSave();
suppressBeforeUnload = true;
window.location.href = doneHref();
}
function saveThenLeaveEditor() {
var href = doneHref();
if (currentConflict) {
setStatus("Conflict");
if (conflictNotice) {
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
clearScheduledSave();
if (isSaving) {
waitForSaveThenNavigate(href);
return;
}
setStatus("Saving");
saveNow()
.then(function (result) {
if (result === "saved" || result === "unchanged") {
suppressBeforeUnload = true;
window.location.href = href;
return;
}
if (result === "conflict") {
setStatus("Conflict");
return;
}
setStatus("Queued");
if (window.confirm("Could not save. Leave without saving?")) {
suppressBeforeUnload = true;
window.location.href = href;
}
})
.catch(function () {
setStatus("Queued");
if (window.confirm("Could not save. Leave without saving?")) {
suppressBeforeUnload = true;
window.location.href = href;
}
});
}
function loadCodeMirror() {
if (codeMirrorReadyPromise) {
return codeMirrorReadyPromise;
}
codeMirrorReadyPromise = Promise.all([
import("/static/codemirror.bundle.js"),
]).then(function (modules) {
var bundle = modules[0];
codeMirrorModules = {
cm: bundle,
markdown: bundle,
vim: bundle,
};
registerVimCommands();
return codeMirrorModules;
});
return codeMirrorReadyPromise;
}
function registerVimCommands() {
var Vim = codeMirrorModules && codeMirrorModules.vim && codeMirrorModules.vim.Vim;
if (!Vim) {
return;
}
Vim.defineEx("write", "w", function () {
saveNow().catch(function () {
setStatus("Queued");
});
});
Vim.defineEx("quit", "q", function () {
leaveEditorNow();
});
Vim.defineEx("wq", "wq", function () {
saveThenLeaveEditor();
});
Vim.defineEx("xit", "x", function () {
saveThenLeaveEditor();
});
Vim.map("<Esc>", ":q<CR>");
}
function codeMirrorTheme() {
var EditorView = codeMirrorModules.cm.EditorView;
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
monacoReadyPromise = new Promise(function (resolve) {
var script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
script.onload = function () {
require.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
});
require(["vs/editor/editor.main"], function () {
monaco.editor.defineTheme(monacoDarkTheme, {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
{ token: "keyword", foreground: "E8943A" },
{ token: "string", foreground: "C9A96E" },
{ token: "number", foreground: "C9A96E" },
{ token: "type", foreground: "E8943A" },
{ token: "tag", foreground: "E8943A" },
{ token: "attribute.name", foreground: "D4A05A" },
{ token: "attribute.value", foreground: "C9A96E" },
{ token: "delimiter", foreground: "B0B8C4" },
{ token: "variable", foreground: "D4D8DE" },
],
colors: {
"editor.background": "#1E1E1E",
"editor.foreground": "#E8ECF0",
"editor.lineHighlightBackground": "#282828",
"editor.selectionBackground": "#3A3A3A",
"editorLineNumber.foreground": "#555555",
"editorLineNumber.activeForeground": "#E8943A",
"editor.inactiveSelectionBackground": "#333333",
"editorCursor.foreground": "#E8943A",
"editorIndentGuide.background": "#2A2A2A",
"editorIndentGuide.activeBackground": "#3A3A3A",
"editorWhitespace.foreground": "#2A2A2A",
"editorGutter.background": "#1E1E1E",
"editorOverviewRuler.border": "#1E1E1E",
"scrollbarSlider.background": "#3A3A3A88",
"scrollbarSlider.hoverBackground": "#4A4A4A88",
"scrollbarSlider.activeBackground": "#5A5A5A88",
"minimap.background": "#1E1E1E",
},
});
return EditorView.theme({
"&": {
height: "100%",
minHeight: "60vh",
backgroundColor: "var(--panel)",
color: "var(--text)",
fontSize: "14px",
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
lineHeight: "22px",
},
".cm-content": {
padding: "12px 0",
caretColor: "var(--accent)",
},
".cm-line": {
padding: "0 14px",
},
".cm-gutters": {
backgroundColor: "var(--panel-strong)",
borderRight: "1px solid var(--border)",
color: "var(--muted)",
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: isDark ? "oklch(0.24 0.012 55)" : "#f0ede5",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)",
},
".cm-cursor": {
borderLeftColor: "var(--accent)",
},
".cm-panel": {
backgroundColor: "var(--panel-strong)",
borderTop: "1px solid var(--border)",
color: "var(--text)",
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
},
".cm-vim-panel input": {
color: "var(--text)",
},
}, { dark: isDark });
}
monaco.editor.defineTheme(monacoLightTheme, {
base: "vs",
inherit: true,
rules: [
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
{ token: "keyword", foreground: "C67A2A" },
{ token: "string", foreground: "8A6D3B" },
{ token: "number", foreground: "8A6D3B" },
{ token: "type", foreground: "C67A2A" },
{ token: "tag", foreground: "C67A2A" },
{ token: "attribute.name", foreground: "A87D3A" },
{ token: "attribute.value", foreground: "8A6D3B" },
],
colors: {
"editor.background": "#FCFAF5",
"editor.foreground": "#1C2430",
"editor.lineHighlightBackground": "#F0EDE5",
"editor.selectionBackground": "#E8943A33",
"editorLineNumber.foreground": "#AAAAAA",
"editorLineNumber.activeForeground": "#C67A2A",
"editorCursor.foreground": "#C67A2A",
"editorIndentGuide.background": "#E8E5DD",
"editorGutter.background": "#FCFAF5",
},
});
function codeMirrorHighlighting() {
var cm = codeMirrorModules.cm;
var tags = codeMirrorModules.cm.tags;
var urlColor = window.matchMedia("(prefers-color-scheme: dark)").matches
? "oklch(0.78 0.14 190)"
: "#047c78";
if (monacoContainer) {
editor = monaco.editor.create(monacoContainer, {
value: textarea.value,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
return cm.syntaxHighlighting(cm.HighlightStyle.define([
{
tag: [tags.url, tags.link],
color: urlColor,
textDecoration: "underline",
textUnderlineOffset: "2px",
},
]));
}
textarea.hidden = true;
monacoContainer.classList.add("monaco-mounted");
function createCodeMirrorEditor(mount, initialValue, options) {
var cm = codeMirrorModules.cm;
var markdownModule = codeMirrorModules.markdown;
var vimModule = codeMirrorModules.vim;
var onChange = options && options.onChange ? options.onChange : function () {};
var extensions = [
vimModule.vim({ status: true }),
cm.basicSetup,
markdownModule.markdown({
base: markdownModule.markdownLanguage,
}),
cm.EditorView.lineWrapping,
codeMirrorTheme(),
codeMirrorHighlighting(),
cm.EditorView.updateListener.of(function (update) {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}),
];
editor.onDidChangeModelContent(function () {
textarea.value = editor.getValue();
scheduleSave();
});
mount.replaceChildren();
mount.classList.add("codemirror-mounted");
if (window.MDHubSync) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
saveNow().catch(function () {
setStatus("Queued");
});
});
}
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
darkMQ.addEventListener("change", function (e) {
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
});
editor.focus();
}
resolve();
});
};
document.head.appendChild(script);
var view = new cm.EditorView({
doc: initialValue,
extensions: extensions,
parent: mount,
});
return monacoReadyPromise;
return {
view: view,
getValue: function () {
return view.state.doc.toString();
},
setValue: function (value) {
if (view.state.doc.toString() === value) {
return;
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
},
focus: function () {
view.focus();
},
hasTextFocus: function () {
return view.hasFocus;
},
destroy: function () {
view.destroy();
},
};
}
function ensureResolutionEditor() {
@@ -467,54 +567,42 @@
return;
}
loadMonaco().then(function () {
if (!window.monaco || resolutionEditor) {
loadCodeMirror().then(function () {
if (resolutionEditor) {
return;
}
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
value: conflictResolution.value || resolvedConflictContent,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
resolutionEditor = createCodeMirrorEditor(conflictResolutionMount, conflictResolution.value || resolvedConflictContent, {
onChange: function (value) {
if (suppressResolutionEvents) {
return;
}
resolvedConflictContent = value;
conflictResolution.value = value;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
},
});
conflictResolution.hidden = true;
conflictResolutionMount.classList.add("monaco-mounted");
resolutionEditor.onDidChangeModelContent(function () {
if (suppressResolutionEvents) {
return;
}
resolvedConflictContent = resolutionEditor.getValue();
conflictResolution.value = resolvedConflictContent;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
resolutionEditor.focus();
}).catch(function () {
conflictResolution.hidden = false;
});
}
if (monacoContainer) {
loadMonaco().catch(function () {
if (codeMirrorContainer) {
loadCodeMirror().then(function () {
editor = createCodeMirrorEditor(codeMirrorContainer, textarea.value, {
onChange: function (value) {
textarea.value = value;
scheduleSave();
},
});
textarea.hidden = true;
editor.focus();
}).catch(function () {
textarea.addEventListener("input", scheduleSave);
});
} else {
@@ -582,59 +670,10 @@
restorePendingConflict();
var doneLink = document.querySelector("[data-done-link]");
var isSaving = false;
if (doneLink) {
doneLink.addEventListener("click", function (event) {
var currentValue = getEditorValue();
var href = doneLink.getAttribute("href");
// If there's an unresolved conflict, warn the user
if (currentConflict) {
event.preventDefault();
setStatus("Conflict");
if (conflictNotice) {
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
// If nothing changed, allow normal navigation
if (currentValue === lastSavedValue) {
return;
}
// Unsaved changes — cancel any pending timer and save immediately
event.preventDefault();
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (isSaving) {
// Already saving, just wait and then navigate
waitForSaveThenNavigate(href);
return;
}
isSaving = true;
setStatus("Saving");
saveNow()
.then(function () {
isSaving = false;
window.location.href = href;
})
.catch(function () {
isSaving = false;
setStatus("Queued");
// If save fails, ask user if they want to leave anyway
if (window.confirm("Could not save. Leave without saving?")) {
window.location.href = href;
}
});
saveThenLeaveEditor();
});
}
@@ -642,6 +681,14 @@
var checkInterval = setInterval(function () {
if (!isSaving) {
clearInterval(checkInterval);
if (currentConflict) {
setStatus("Conflict");
if (conflictNotice) {
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
suppressBeforeUnload = true;
window.location.href = href;
}
}, 100);
@@ -653,15 +700,42 @@
// Warn on browser unload if there are unsaved changes
window.addEventListener("beforeunload", function (e) {
if (suppressBeforeUnload) {
return;
}
if (getEditorValue() !== lastSavedValue || currentConflict) {
e.preventDefault();
e.returnValue = "";
}
});
function editorSurfaceHasFocus(target) {
if (!target) {
return false;
}
if (target === textarea || target.closest("[data-codemirror-mount], [data-conflict-codemirror-mount], [data-document-editor], .cm-editor")) {
return true;
}
return Boolean(editor && editor.hasTextFocus && editor.hasTextFocus());
}
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
saveNow().catch(function () {
setStatus("Queued");
});
return;
}
});
if (e.metaKey && e.key === "Enter") {
e.preventDefault();
saveThenLeaveEditor();
return;
}
if (!editorSurfaceHasFocus(e.target)) {
return;
}
}, true);
})();