Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -1,8 +1,10 @@
(function () {
const DB_NAME = "md-hub-cache";
const DB_VERSION = 1;
const DB_VERSION = 3;
const STORE_DOCUMENTS = "documents";
const STORE_CONTENT = "content";
const STORE_PAGES = "pages";
const STORE_PENDING_EDITS = "pending_edits";
function openDB() {
return new Promise(function (resolve, reject) {
@@ -21,10 +23,22 @@
if (!db.objectStoreNames.contains(STORE_CONTENT)) {
db.createObjectStore(STORE_CONTENT, { keyPath: "path" });
}
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
if (!db.objectStoreNames.contains(STORE_PENDING_EDITS)) {
db.createObjectStore(STORE_PENDING_EDITS, { keyPath: "path" });
}
};
});
}
function normalizePath(path) {
if (!path) return "/";
if (path === "/") return path;
return path.replace(/\/$/, "") || "/";
}
function cacheDocuments(documents) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
@@ -91,10 +105,158 @@
});
}
function cachePage(path, html, title) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put({
path: normalizedPath,
html: html,
title: title,
cachedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getCachedPage(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function putPendingEdit(path, content, baseHash) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.put({
path: normalizedPath,
content: content,
baseHash: baseHash || "",
conflict: null,
retries: 0,
updatedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function markPendingEditConflict(path, conflict) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
const record = request.result;
if (!record) {
resolve();
return;
}
record.conflict = conflict;
record.updatedAt = Date.now();
store.put(record);
};
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getPendingEdits() {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.getAll();
request.onsuccess = function () {
resolve(request.result || []);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function getPendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function deletePendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.delete(normalizedPath);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
window.MDHubCache = {
cacheDocuments: cacheDocuments,
cacheContent: cacheContent,
cachePage: cachePage,
putPendingEdit: putPendingEdit,
markPendingEditConflict: markPendingEditConflict,
getPendingEdits: getPendingEdits,
getPendingEdit: getPendingEdit,
deletePendingEdit: deletePendingEdit,
getCachedDocuments: getCachedDocuments,
getCachedContent: getCachedContent,
getCachedPage: getCachedPage,
normalizePath: normalizePath,
};
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -0,0 +1,579 @@
(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-monaco-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";
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;
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;
}
hideConflict();
setStatus("Saving");
if (!window.MDHubSync) {
setStatus("Queued");
return;
}
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");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
return;
}
setStatus("Queued");
}
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 loadMonaco() {
if (monacoReadyPromise) {
return monacoReadyPromise;
}
if (monacoLoaded && window.monaco) {
monacoReadyPromise = Promise.resolve();
return monacoReadyPromise;
}
monacoLoaded = true;
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",
},
});
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",
},
});
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,
});
textarea.hidden = true;
monacoContainer.classList.add("monaco-mounted");
editor.onDidChangeModelContent(function () {
textarea.value = editor.getValue();
scheduleSave();
});
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);
});
return monacoReadyPromise;
}
function ensureResolutionEditor() {
if (!conflictResolution || !conflictResolutionMount) {
return;
}
loadMonaco().then(function () {
if (!window.monaco || 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,
});
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 () {
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();
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
}
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -2,8 +2,10 @@
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
const cachedNotice = document.querySelector("[data-cached-notice]");
const documentShell = document.querySelector("[data-document-path][data-document-hash]");
const browser = document.querySelector(".miller-browser");
const isEditing = Boolean(document.querySelector("[data-editor-form]"));
// Auto-scroll miller browser to show the rightmost (active) column
function scrollBrowserToRight() {
@@ -23,6 +25,120 @@
scrollBrowserToRight();
function initColumnPreviewExpansion() {
if (!browser) {
return;
}
let hoverTimer = null;
let expandedColumn = null;
function isCompressedColumn(column) {
return (
column &&
column.matches(".miller-column:not(:first-child):not(:last-child)")
);
}
function collapseColumn(column) {
if (!column) return;
column.classList.remove("is-preview-expanded");
column.style.removeProperty("--expanded-column-width");
if (expandedColumn === column) {
expandedColumn = null;
}
}
function expandColumn(column) {
if (!isCompressedColumn(column)) return;
if (expandedColumn && expandedColumn !== column) {
collapseColumn(expandedColumn);
}
const expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
column.classList.add("is-preview-expanded");
expandedColumn = column;
requestAnimationFrame(function () {
const leftEdge = column.offsetLeft;
const rightEdge = leftEdge + expandedWidth;
let targetLeft = browser.scrollLeft;
if (leftEdge < browser.scrollLeft) {
targetLeft = leftEdge - 8;
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
targetLeft = rightEdge - browser.clientWidth + 8;
}
browser.scrollTo({
left: Math.max(0, targetLeft),
behavior: "smooth",
});
});
}
function scheduleExpansion(column) {
if (!isCompressedColumn(column)) return;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(function () {
expandColumn(column);
}, 1000);
}
browser.querySelectorAll(".miller-column").forEach(function (column) {
column.addEventListener("pointerenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseover", function () {
if (!isCompressedColumn(column)) return;
if (expandedColumn === column) return;
scheduleExpansion(column);
});
column.addEventListener("pointerleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
column.addEventListener("mouseleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
});
}
initColumnPreviewExpansion();
function updateCachedUI(isCached) {
if (!cachedNotice) return;
cachedNotice.hidden = !isCached;
}
function cacheCurrentPage() {
if (!window.MDHubCache) return;
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
registerServiceWorker();
updateCachedUI(Boolean(window.__MDHUB_OFFLINE_CACHED__));
function updateOfflineUI() {
if (!offlineNotice) return;
if (navigator.onLine) {
@@ -54,6 +170,8 @@
}
}
cacheCurrentPage();
// Fetch and cache documents
function fetchAndCacheDocuments() {
if (!window.MDHubCache) return;
@@ -130,6 +248,9 @@
const bodyEl = documentShell.querySelector(".markdown-body");
if (bodyEl) bodyEl.innerHTML = html;
updateCachedUI(true);
cacheCurrentPage();
// Re-render math and mermaid
if (typeof renderMath === "function") renderMath();
if (typeof renderMermaid === "function") renderMermaid();
@@ -185,6 +306,10 @@
const change = payload.data;
if (isEditing && currentPath && change.path === currentPath) {
return;
}
if (currentPath && change.path === currentPath && change.hash !== currentHash) {
if (notice) notice.hidden = false;
return;

View File

@@ -1,5 +1,5 @@
:root {
color-scheme: light;
color-scheme: light dark;
--bg: #fbf7ef;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
@@ -15,10 +15,28 @@
font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.14 0.012 55);
--panel: oklch(0.18 0.014 55 / 0.85);
--panel-strong: oklch(0.21 0.015 55);
--text: oklch(0.93 0.006 55);
--muted: oklch(0.66 0.018 55);
--accent: oklch(0.705 0.165 55);
--accent-soft: oklch(0.705 0.165 55 / 0.14);
--border: oklch(0.27 0.018 55);
--shadow: 0 16px 48px oklch(0.08 0.02 55 / 0.35);
}
}
* {
box-sizing: border-box;
}
html {
transition: color-scheme 0.4s ease;
}
body {
margin: 0;
min-height: 100vh;
@@ -27,6 +45,18 @@ body {
radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 34%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%),
linear-gradient(180deg, #eef6ff 0%, #dbeafe 100%);
transition:
color 0.35s ease,
background 0.55s ease;
}
@media (prefers-color-scheme: dark) {
body {
background:
radial-gradient(circle at top left, oklch(0.24 0.04 55 / 0.45), transparent 36%),
radial-gradient(circle at bottom right, oklch(0.20 0.035 50 / 0.35), transparent 32%),
linear-gradient(180deg, oklch(0.12 0.012 55) 0%, oklch(0.14 0.012 55) 100%);
}
}
a {
@@ -60,12 +90,20 @@ code {
}
.site-brand {
color: var(--text);
font-size: 1.15rem;
font-weight: 700;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
text-decoration: none;
}
.site-brand img {
display: block;
width: auto;
height: 2.5rem;
max-width: min(12rem, 42vw);
object-fit: contain;
}
.site-nav {
display: flex;
gap: 1.25rem;
@@ -157,6 +195,7 @@ code {
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 180ms ease, min-width 180ms ease, box-shadow 180ms ease;
}
/* Root column - fixed width for top-level navigation */
@@ -170,6 +209,18 @@ code {
width: 48px;
}
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
width: var(--expanded-column-width, max-content);
min-width: 12rem;
max-width: 24rem;
box-shadow: 8px 0 24px rgba(24, 32, 42, 0.08);
}
.miller-column:not(:first-child):not(:last-child):hover {
transition-delay: 1s;
}
/* Active/last column - grows to fill remaining sidebar space */
.miller-column:last-child {
flex: 1 1 auto;
@@ -207,6 +258,17 @@ code {
vertical-align: bottom;
}
.miller-column:not(:first-child):not(:last-child):hover h2,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 {
padding: 0.7rem 0.8rem;
text-align: left;
}
.miller-column:not(:first-child):not(:last-child):hover h2 .miller-column-title-text,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 .miller-column-title-text {
max-width: min(20rem, calc(var(--expanded-column-width, 14rem) - 2rem));
}
.miller-column ul {
margin: 0;
padding: 0.3rem;
@@ -252,6 +314,28 @@ code {
display: none;
}
.miller-column:not(:first-child):not(:last-child):hover a,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded a {
padding: 0.4rem 0.5rem;
justify-content: space-between;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-label,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-label {
justify-content: flex-start;
gap: 0.4rem;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-name,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-name {
max-width: min(18rem, calc(var(--expanded-column-width, 14rem) - 3.5rem));
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-chevron,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-chevron {
display: inline-block;
}
.browser-item-label {
display: inline-flex;
min-width: 0;
@@ -260,69 +344,24 @@ code {
}
.browser-icon {
position: relative;
display: inline-block;
width: 1rem;
height: 0.9rem;
height: 1rem;
flex: 0 0 auto;
}
.browser-icon--folder {
margin-top: 0.1rem;
border: 1px solid rgba(15, 91, 216, 0.24);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--folder::before {
position: absolute;
top: -0.22rem;
left: 0.1rem;
width: 0.45rem;
height: 0.25rem;
border: 1px solid rgba(15, 91, 216, 0.24);
border-bottom: 0;
border-radius: 0;
background: var(--accent-soft);
content: "";
color: #0f5bd8;
}
.browser-icon--page {
border: 1px solid var(--border);
border-radius: 0;
background: rgba(255, 255, 255, 0.72);
}
.browser-icon--page::before {
position: absolute;
right: -1px;
top: -1px;
width: 0.3rem;
height: 0.3rem;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--panel);
content: "";
color: #465365;
}
.browser-icon--root {
width: 0.85rem;
height: 0.85rem;
border: 1.5px solid var(--accent);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--root::before {
position: absolute;
top: 50%;
left: 50%;
width: 0.3rem;
height: 0.3rem;
border-radius: 0;
background: var(--accent);
transform: translate(-50%, -50%);
content: "";
width: 0.95rem;
height: 0.95rem;
color: var(--accent);
}
.miller-column--root h2 {
@@ -343,6 +382,12 @@ code {
white-space: nowrap;
}
.browser-item-chevron {
width: 0.95rem;
height: 0.95rem;
flex: 0 0 auto;
}
/* Document shell */
.document-shell,
.error-panel,
@@ -447,11 +492,51 @@ code {
}
.document-meta h1 {
margin: 0 0 0.75rem;
margin: 0;
font-size: 1.85rem;
line-height: 1.1;
}
.document-meta-panel {
margin-top: 0.9rem;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.45);
}
.document-meta-panel summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.85rem;
cursor: pointer;
list-style: none;
color: var(--muted);
font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.document-meta-panel summary::-webkit-details-marker {
display: none;
}
.document-meta-panel summary::after {
content: "+";
margin-left: auto;
color: var(--text);
font-size: 1rem;
letter-spacing: 0;
}
.document-meta-panel[open] summary::after {
content: "-";
}
.document-meta-panel .meta-grid {
padding: 0 0.85rem 0.85rem;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -535,6 +620,26 @@ code {
display: none;
}
.cached-notice {
position: fixed;
left: 1rem;
bottom: 4.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
max-width: min(28rem, calc(100vw - 2rem));
padding: 0.6rem 0.85rem;
border: 1px solid rgba(37, 99, 235, 0.25);
border-radius: 0;
background: rgba(239, 246, 255, 0.96);
color: #1d4ed8;
font-size: 0.9rem;
}
.cached-notice[hidden] {
display: none;
}
.offline-icon {
display: inline-block;
width: 0.6rem;
@@ -544,6 +649,15 @@ code {
flex-shrink: 0;
}
.cached-icon {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 0;
background: #2563eb;
flex-shrink: 0;
}
.version-notice[hidden] {
display: none;
}
@@ -564,6 +678,284 @@ code {
cursor: pointer;
}
.document-meta__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.document-meta__header--editor {
align-items: end;
}
.document-edit-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
padding: 0 0.9rem;
border: 1px solid var(--border);
color: var(--text);
text-decoration: none;
background: var(--panel);
}
.document-shell--editor {
display: grid;
grid-template-rows: auto auto 1fr;
min-height: 0;
}
.editor-form {
min-height: 0;
position: relative;
}
.editor-form[hidden] {
display: none;
}
[data-monaco-mount] {
display: none;
width: 100%;
min-height: 60vh;
height: 100%;
border: 1px solid var(--border);
overflow: hidden;
}
[data-monaco-mount].monaco-mounted {
display: block;
}
.editor-form textarea {
width: 100%;
min-height: 60vh;
height: 100%;
resize: vertical;
border: 1px solid var(--border);
background: #fbfbf9;
color: var(--text);
padding: 1rem;
font: 400 0.98rem/1.6 ui-monospace, SFMono-Regular, monospace;
}
[data-monaco-mount].monaco-mounted ~ textarea {
display: none;
}
@keyframes monaco-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
[data-monaco-mount]:not(.monaco-mounted) {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, var(--panel-strong) 25%, var(--panel) 50%, var(--panel-strong) 75%);
background-size: 200% 100%;
animation: monaco-shimmer 1.5s ease infinite;
color: var(--muted);
font-size: 0.85rem;
}
.editor-status {
display: inline-block;
min-width: 5.5rem;
font-weight: 600;
text-align: right;
}
.editor-status[data-state="queued"] {
color: #92400e;
}
.editor-status[data-state="conflict"] {
color: #b91c1c;
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: #1d4ed8;
}
.editor-conflict {
display: grid;
gap: 0.75rem;
margin-top: 0.8rem;
padding: 0.75rem 0.9rem;
border: 1px solid #fecaca;
background: #fff1f2;
color: #7f1d1d;
}
.editor-conflict[hidden] {
display: none;
}
.editor-conflict__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.editor-conflict__header > div:first-child {
display: grid;
gap: 0.2rem;
}
.editor-conflict__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.editor-conflict__actions button {
min-height: 2.1rem;
padding: 0 0.75rem;
border: 1px solid #fecaca;
background: #fff;
color: #7f1d1d;
cursor: pointer;
}
.editor-conflict__actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.editor-conflict__choices {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.editor-conflict__choices button {
min-height: 2rem;
padding: 0 0.65rem;
border: 1px solid rgba(127, 29, 29, 0.2);
background: rgba(255, 255, 255, 0.72);
color: #7f1d1d;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: rgba(185, 28, 28, 0.25);
background: rgba(254, 202, 202, 0.6);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: rgba(22, 101, 52, 0.25);
background: rgba(187, 247, 208, 0.55);
color: #14532d;
}
.editor-conflict p {
margin: 0;
}
.editor-conflict__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.86rem;
}
.editor-conflict__legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.editor-conflict__swatch {
width: 0.75rem;
height: 0.75rem;
border: 1px solid currentColor;
display: inline-block;
}
.editor-conflict__swatch--server {
background: rgba(200, 85, 61, 0.2);
color: #b91c1c;
}
.editor-conflict__swatch--local {
background: rgba(63, 143, 89, 0.2);
color: #166534;
}
.editor-conflict__diff {
min-height: 16rem;
overflow: auto;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
}
.editor-conflict code {
word-break: break-all;
}
.editor-conflict__resolution {
display: grid;
gap: 0.4rem;
}
.editor-conflict__resolution label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-conflict__resolution textarea {
width: 100%;
min-height: 16rem;
resize: vertical;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
color: #1c2430;
font-family: "Iosevka", "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 0.9rem;
line-height: 1.55;
padding: 0.75rem;
}
[data-conflict-monaco-mount] {
display: none;
width: 100%;
min-height: 18rem;
height: 32rem;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
overflow: hidden;
}
[data-conflict-monaco-mount].monaco-mounted {
display: block;
}
[data-conflict-monaco-mount].monaco-mounted + textarea {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Adaptive breakpoints */
@media (max-width: 1024px) {
.workspace-shell,
@@ -579,6 +971,10 @@ code {
}
@media (max-width: 768px) {
.site-brand img {
height: 2.25rem;
}
.site-header__inner {
flex-direction: column;
align-items: flex-start;
@@ -655,6 +1051,12 @@ code {
.meta-grid {
grid-template-columns: 1fr;
}
.document-meta-panel summary,
.document-meta__header {
align-items: flex-start;
flex-direction: column;
}
}
@media (min-width: 1600px) {
@@ -709,3 +1111,149 @@ code {
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, monospace;
}
@media (prefers-color-scheme: dark) {
.site-header {
border-bottom-color: oklch(0.28 0.018 55 / 0.5);
background: oklch(0.16 0.014 55 / 0.92);
}
.miller-column h2 {
background: oklch(0.20 0.015 55 / 0.72);
}
.miller-column a:hover,
.miller-column a.is-active {
background: var(--accent-soft);
}
.browser-icon--folder {
color: oklch(0.705 0.165 55);
}
.browser-icon--page {
color: oklch(0.66 0.018 55);
}
.markdown-body pre {
background: oklch(0.12 0.015 55);
color: oklch(0.93 0.006 55);
}
.markdown-body blockquote {
border-left-color: var(--accent);
background: oklch(0.705 0.165 55 / 0.07);
}
.document-meta-panel {
background: oklch(0.20 0.015 55 / 0.5);
}
.editor-form textarea {
background: oklch(0.15 0.012 55);
color: var(--text);
border-color: var(--border);
}
.editor-conflict {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.22 0.04 25);
color: oklch(0.75 0.12 25);
}
.editor-conflict__actions button {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button {
border-color: oklch(0.55 0.18 25 / 0.35);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: oklch(0.65 0.18 25 / 0.45);
background: oklch(0.28 0.08 25 / 0.8);
color: oklch(0.78 0.13 25);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: oklch(0.65 0.15 145 / 0.45);
background: oklch(0.25 0.07 145 / 0.8);
color: oklch(0.78 0.12 145);
}
.editor-conflict__swatch--server {
background: oklch(0.35 0.12 25 / 0.65);
color: oklch(0.72 0.14 25);
}
.editor-conflict__swatch--local {
background: oklch(0.33 0.10 145 / 0.65);
color: oklch(0.72 0.13 145);
}
.editor-conflict__diff {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.editor-conflict__resolution textarea {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
color: var(--text);
}
[data-conflict-monaco-mount] {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.offline-notice {
border-color: oklch(0.75 0.15 85 / 0.4);
background: oklch(0.22 0.03 85 / 0.95);
color: oklch(0.75 0.12 85);
}
.offline-icon {
background: oklch(0.78 0.165 85);
}
.cached-notice {
border-color: oklch(0.55 0.15 250 / 0.3);
background: oklch(0.20 0.035 250 / 0.96);
color: oklch(0.65 0.12 250);
}
.cached-icon {
background: oklch(0.60 0.15 250);
}
.version-notice button {
background: var(--accent);
}
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
box-shadow: 8px 0 24px oklch(0.08 0.02 55 / 0.3);
}
.editor-status[data-state="queued"] {
color: oklch(0.65 0.12 75);
}
.editor-status[data-state="conflict"] {
color: oklch(0.60 0.18 25);
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: oklch(0.65 0.12 250);
}
.markdown-body blockquote {
background: oklch(0.705 0.165 55 / 0.07);
}
}

View File

@@ -0,0 +1,204 @@
const STATIC_CACHE = "md-hub-static-v1";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";
const STATIC_ASSETS = [
"/static/site.css",
"/static/cache.js",
"/static/sync.js",
"/static/realtime.js",
"/static/editor.js",
"/static/render.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
"/docs",
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(STATIC_CACHE).then(function (cache) {
return cache.addAll(STATIC_ASSETS);
}).then(function () {
return self.skipWaiting();
})
);
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key === STATIC_CACHE) {
return null;
}
return caches.delete(key);
})
);
}).then(function () {
return self.clients.claim();
})
);
});
self.addEventListener("fetch", function (event) {
const request = event.request;
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (request.mode === "navigate") {
event.respondWith(handleNavigation(request, url));
return;
}
if (url.pathname.startsWith("/static/")) {
event.respondWith(handleStaticAsset(request));
}
});
async function handleNavigation(request, url) {
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-MDHub-Cache": "offline",
},
status: 200,
});
}
const fallback = await caches.match("/");
if (fallback) {
return fallback;
}
return new Response("Offline", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
}
async function handleStaticAsset(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) {
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response.clone());
}
}).catch(function () {});
return cached;
}
const response = await fetch(request);
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
}
async function cachePageResponse(pathname, response) {
if (!response || !response.ok) {
return;
}
const contentType = response.headers.get("Content-Type") || "";
if (contentType.indexOf("text/html") === -1) {
return;
}
try {
const html = await response.text();
await putCachedPage(normalizePath(pathname), {
path: normalizePath(pathname),
html: html,
title: extractTitle(html),
cachedAt: Date.now(),
});
} catch (_error) {}
}
function markOfflineHTML(html) {
const marker = "<script>window.__MDHUB_OFFLINE_CACHED__=true;<\/script>";
if (html.indexOf("window.__MDHUB_OFFLINE_CACHED__") !== -1) {
return html;
}
if (html.indexOf("</head>") !== -1) {
return html.replace("</head>", marker + "</head>");
}
return marker + html;
}
function extractTitle(html) {
const match = html.match(/<title>([^<]*)<\/title>/i);
return match ? match[1] : "Cairnquire";
}
function normalizePath(path) {
if (!path || path === "/") {
return "/";
}
return path.replace(/\/$/, "") || "/";
}
function openDB() {
return new Promise(function (resolve, reject) {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = function () {
reject(request.error);
};
request.onsuccess = function () {
resolve(request.result);
};
request.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
};
});
}
async function putCachedPage(path, record) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put(record);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
}
async function getCachedPage(path) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizePath(path));
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
}

View File

@@ -0,0 +1,96 @@
(function () {
if (!window.MDHubCache) {
return;
}
function normalizeDocPath(path) {
if (!path) return "";
return path.replace(/^\/+/, "");
}
async function postDocument(path, content, baseHash) {
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: content, baseHash: baseHash || "" }),
});
const payload = await response.json().catch(function () {
return {};
});
if (!response.ok) {
if (response.status === 409) {
const conflict = new Error("document conflict");
conflict.name = "DocumentConflictError";
conflict.details = payload;
throw conflict;
}
throw new Error("save failed");
}
return payload;
}
async function saveDocument(path, content, baseHash) {
const normalizedPath = window.MDHubCache.normalizePath("/docs/" + normalizeDocPath(path));
try {
const result = await postDocument(path, content, baseHash);
await window.MDHubCache.deletePendingEdit(normalizedPath);
return {
status: "saved",
result: result,
};
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
return {
status: "conflict",
conflict: error.details,
};
}
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
return {
status: "queued",
};
}
}
async function syncPending() {
if (!navigator.onLine) {
return;
}
const pending = await window.MDHubCache.getPendingEdits();
pending.sort(function (a, b) {
return (a.updatedAt || 0) - (b.updatedAt || 0);
});
for (const edit of pending) {
try {
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
await window.MDHubCache.deletePendingEdit(edit.path);
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.markPendingEditConflict(edit.path, error.details);
}
break;
}
}
}
window.addEventListener("online", function () {
syncPending().catch(function () {});
});
syncPending().catch(function () {});
window.MDHubSync = {
saveDocument: saveDocument,
syncPending: syncPending,
};
})();