Files
cairnquire/apps/server/internal/httpserver/static/editor.js
2026-06-12 12:12:49 -04:00

742 lines
21 KiB
JavaScript

(function () {
var form = document.querySelector("[data-editor-form]");
var textarea = document.querySelector("[data-document-editor]");
var statusNodes = document.querySelectorAll("[data-sync-status], [data-sync-status-secondary]");
var hashEl = document.querySelector("[data-editor-hash]");
var documentShell = document.querySelector("[data-document-path][data-document-hash]");
var conflictNotice = document.querySelector("[data-conflict-notice]");
var conflictHash = document.querySelector("[data-conflict-hash]");
var conflictStatus = document.querySelector("[data-conflict-status]");
var conflictDiff = document.querySelector("[data-conflict-diff]");
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-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 codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
var codeMirrorReadyPromise = null;
var codeMirrorModules = null;
if (!form || !textarea || statusNodes.length === 0) {
return;
}
var path = form.getAttribute("data-document-path");
var baseHash = documentShell ? documentShell.getAttribute("data-document-hash") : "";
var saveTimer = null;
var lastSavedValue = textarea.value;
var editor = null;
var currentConflict = null;
var currentConflictLocalContent = "";
var resolvedConflictContent = "";
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) {
node.textContent = nextStatus;
node.dataset.state = nextStatus.toLowerCase();
});
}
function getEditorValue() {
return editor ? editor.getValue() : textarea.value;
}
function setEditorValue(value) {
textarea.value = value;
if (editor && editor.getValue() !== value) {
editor.setValue(value);
}
}
function getResolutionValue() {
return resolutionEditor ? resolutionEditor.getValue() : resolvedConflictContent;
}
function setResolutionEditorValue(value) {
resolvedConflictContent = value;
if (conflictResolution) {
conflictResolution.value = value;
}
if (resolutionEditor && resolutionEditor.getValue() !== value) {
suppressResolutionEvents = true;
resolutionEditor.setValue(value);
suppressResolutionEvents = false;
}
}
function ensureTrailingNewline(value) {
return value.endsWith("\n") ? value : value + "\n";
}
function buildCombinedResolution(serverContent, localContent) {
return ensureTrailingNewline(serverContent) + "\n" + ensureTrailingNewline(localContent);
}
function setConflictMessage(message) {
if (conflictStatus) {
conflictStatus.textContent = message;
}
}
function setResolutionContent(content, message) {
setResolutionEditorValue(content);
if (conflictApply) {
conflictApply.disabled = false;
}
if (message) {
setConflictMessage(message);
}
}
async function loadDiffs() {
var modules = await Promise.all([
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/+esm"),
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/dist/style.js"),
]);
return {
renderer: modules[0],
stylesheet: modules[1].default || "",
};
}
function styleDiffContainer(diffContainer) {
diffContainer.style.setProperty("--diffs-light-bg", "#fcfaf5");
diffContainer.style.setProperty("--diffs-light", "#1c2430");
diffContainer.style.setProperty("--diffs-dark-bg", "#1e1e1e");
diffContainer.style.setProperty("--diffs-dark", "#e8ecf0");
diffContainer.style.setProperty("--diffs-addition-color", "#3f8f59");
diffContainer.style.setProperty("--diffs-deletion-color", "#c8553d");
diffContainer.style.setProperty("--diffs-modified-color", "#c67a2a");
diffContainer.style.setProperty("--diffs-font-family", "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace");
diffContainer.style.setProperty("--diffs-header-font-family", "'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif");
}
function injectDiffStyles(diffContainer, stylesheet) {
var root = diffContainer.shadowRoot;
if (!root || !stylesheet || root.querySelector("[data-cairnquire-diffs-style]")) {
return;
}
var style = document.createElement("style");
style.setAttribute("data-cairnquire-diffs-style", "");
style.textContent = stylesheet;
root.prepend(style);
}
function renderConflictResolver(conflict, localContent) {
if (!conflictDiff) {
return;
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
conflictDiff.replaceChildren();
currentConflictLocalContent = localContent;
setResolutionEditorValue(localContent);
if (conflictApply) {
conflictApply.disabled = false;
}
ensureResolutionEditor();
setConflictMessage("Loading conflict resolver...");
loadDiffs().then(function (diffsModule) {
var diffs = diffsModule.renderer;
var diffContainer = document.createElement("div");
styleDiffContainer(diffContainer);
conflictDiff.appendChild(diffContainer);
var serverFile = {
name: path,
contents: conflict.currentContent || "",
lang: "markdown",
cacheKey: "server:" + (conflict.currentHash || ""),
};
var localFile = {
name: path,
contents: localContent,
lang: "markdown",
cacheKey: "local:" + (conflict.baseHash || "") + ":" + localContent.length,
};
conflictDiffView = new diffs.FileDiff({
disableFileHeader: false,
diffStyle: "unified",
diffIndicators: "classic",
lineDiffType: "word",
overflow: "scroll",
parseDiffOptions: { context: 4 },
});
conflictDiffView.render({
oldFile: serverFile,
newFile: localFile,
fileContainer: diffContainer,
});
injectDiffStyles(diffContainer, diffsModule.stylesheet);
setConflictMessage("Red deletions are from the server copy. Green additions are from your queued edit.");
}).catch(function () {
setConflictMessage("Could not load the diff renderer. Your edit is still queued locally.");
});
}
function showConflict(conflict, localContent) {
currentConflict = conflict;
setStatus("Conflict");
form.hidden = true;
if (conflictHash && conflict && conflict.currentHash) {
conflictHash.textContent = conflict.currentHash;
}
if (conflictNotice) {
conflictNotice.hidden = false;
}
renderConflictResolver(conflict, localContent || getEditorValue());
}
function hideConflict() {
form.hidden = false;
if (conflictNotice) {
conflictNotice.hidden = true;
}
if (conflictDiff) {
conflictDiff.replaceChildren();
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
if (conflictApply) {
conflictApply.disabled = true;
}
setResolutionEditorValue("");
currentConflict = null;
currentConflictLocalContent = "";
}
async function saveNow() {
var value = getEditorValue();
if (value === lastSavedValue) {
setStatus("Saved");
return "unchanged";
}
isSaving = true;
hideConflict();
setStatus("Saving");
if (!window.MDHubSync) {
isSaving = false;
setStatus("Queued");
return "queued";
}
try {
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
if (result.status === "saved") {
lastSavedValue = value;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
setStatus("Saved");
isSaving = false;
return "saved";
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
isSaving = false;
return "conflict";
}
setStatus("Queued");
return "queued";
} catch (err) {
setStatus("Queued");
throw err;
} finally {
isSaving = false;
}
}
async function applyResolvedConflict() {
if (!window.MDHubSync || !currentConflict) {
return;
}
var resolutionContent = getResolutionValue();
resolvedConflictContent = resolutionContent;
setConflictMessage("Saving resolved document...");
if (conflictApply) {
conflictApply.disabled = true;
}
var result = await window.MDHubSync.saveDocument(path, resolutionContent, currentConflict.currentHash);
if (result.status === "saved") {
setEditorValue(resolutionContent);
lastSavedValue = resolutionContent;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
hideConflict();
setStatus("Saved");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, resolutionContent);
return;
}
setStatus("Queued");
setConflictMessage("Resolved document is queued and will retry when the server is reachable.");
}
function restorePendingConflict() {
if (!window.MDHubCache || !window.MDHubCache.getPendingEdit) {
return;
}
var normalizedPath = window.MDHubCache.normalizePath("/docs/" + path.replace(/^\/+/, ""));
window.MDHubCache.getPendingEdit(normalizedPath).then(function (pending) {
if (pending && pending.conflict) {
showConflict(pending.conflict, pending.content || getEditorValue());
}
}).catch(function () {});
}
function scheduleSave() {
setStatus("Unsaved");
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(function () {
saveNow().catch(function () {
setStatus("Queued");
});
}, 2000);
}
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;
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 });
}
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";
return cm.syntaxHighlighting(cm.HighlightStyle.define([
{
tag: [tags.url, tags.link],
color: urlColor,
textDecoration: "underline",
textUnderlineOffset: "2px",
},
]));
}
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());
}
}),
];
mount.replaceChildren();
mount.classList.add("codemirror-mounted");
var view = new cm.EditorView({
doc: initialValue,
extensions: extensions,
parent: mount,
});
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() {
if (!conflictResolution || !conflictResolutionMount) {
return;
}
loadCodeMirror().then(function () {
if (resolutionEditor) {
return;
}
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;
resolutionEditor.focus();
}).catch(function () {
conflictResolution.hidden = false;
});
}
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 {
textarea.addEventListener("input", scheduleSave);
}
form.addEventListener("submit", function (event) {
event.preventDefault();
saveNow().catch(function () {
setStatus("Queued");
});
});
if (conflictApply) {
conflictApply.addEventListener("click", function () {
applyResolvedConflict().catch(function () {
setStatus("Queued");
setConflictMessage("Could not save the resolved document. It remains queued locally.");
});
});
}
if (conflictDismiss) {
conflictDismiss.addEventListener("click", function () {
hideConflict();
});
}
if (conflictResolution) {
conflictResolution.addEventListener("input", function () {
resolvedConflictContent = conflictResolution.value;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
}
if (conflictUseServer) {
conflictUseServer.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(currentConflict.currentContent || "", "Using the server copy as the resolution. You can edit it before applying.");
});
}
if (conflictUseLocal) {
conflictUseLocal.addEventListener("click", function () {
setResolutionContent(currentConflictLocalContent, "Using your queued edit as the resolution. You can edit it before applying.");
});
}
if (conflictUseBoth) {
conflictUseBoth.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(
buildCombinedResolution(currentConflict.currentContent || "", currentConflictLocalContent),
"Using both versions as the resolution. Edit the combined content before applying."
);
});
}
restorePendingConflict();
if (doneLink) {
doneLink.addEventListener("click", function (event) {
event.preventDefault();
saveThenLeaveEditor();
});
}
function waitForSaveThenNavigate(href) {
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);
// Timeout after 10 seconds
setTimeout(function () {
clearInterval(checkInterval);
}, 10000);
}
// 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);
})();