UNPKG

inlinecms

Version:

Real-time inline CMS for Astro with post management, frontmatter editing, and live preview

1,174 lines (1,168 loc) 42.8 kB
var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true, configurable: true, set: (newValue) => all[name] = () => newValue }); }; // src/plugins.ts var exports_plugins = {}; __export(exports_plugins, { setupPostManagement: () => setupPostManagement, setupImagePlugin: () => setupImagePlugin }); function showToast(type, title, message) { let container = document.querySelector(".cms-toasts"); if (!container) { container = document.createElement("div"); container.className = "cms-toasts"; document.body.appendChild(container); } const icons = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" }; const toast = document.createElement("div"); toast.className = `cms-toast cms-toast-${type}`; toast.innerHTML = ` <span>${icons[type]}</span> <div><b>${title}</b><p>${message}</p></div> <button onclick="this.parentElement.remove()">×</button> `; container.appendChild(toast); setTimeout(() => toast.remove(), 5000); if (!document.querySelector("#cms-toast-styles")) { const style = document.createElement("style"); style.id = "cms-toast-styles"; style.textContent = ` .cms-toasts { position: fixed; top: 80px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 10px; } .cms-toast { display: flex; gap: 12px; align-items: center; background: white; border-radius: 8px; padding: 12px 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 300px; animation: slideIn 0.3s; } .cms-toast span { font-size: 20px; } .cms-toast div { flex: 1; } .cms-toast b { font-size: 14px; display: block; margin-bottom: 4px; } .cms-toast p { font-size: 13px; color: #666; margin: 0; } .cms-toast button { background: none; border: none; font-size: 20px; cursor: pointer; color: #999; } .cms-toast-success { border-left: 4px solid #10b981; } .cms-toast-error { border-left: 4px solid #ef4444; } @keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } `; document.head.appendChild(style); } } function ensureModalStyles() { if (!document.querySelector("#cms-modal-styles")) { const style = document.createElement("style"); style.id = "cms-modal-styles"; style.textContent = ` .cms-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10001; animation: fadeIn 0.2s; } .cms-modal-content { background: white; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); } .cms-modal-content h3 { margin: 0 0 16px 0; font-size: 20px; } .cms-modal-body { margin-bottom: 20px; } .cms-modal-buttons { display: flex; gap: 12px; justify-content: flex-end; } .cms-btn { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px; font-weight: 500; } .cms-btn-primary { background: #3b82f6; color: white; } .cms-btn-secondary { background: #f1f5f9; color: #64748b; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } `; document.head.appendChild(style); } } function createModalContainer() { ensureModalStyles(); const modal = document.createElement("div"); modal.className = "cms-modal"; const s = modal.style; s.setProperty("position", "fixed", "important"); s.setProperty("inset", "0px", "important"); s.setProperty("background", "rgba(0,0,0,0.5)", "important"); s.setProperty("display", "flex", "important"); s.setProperty("align-items", "center", "important"); s.setProperty("justify-content", "center", "important"); s.setProperty("z-index", "10001", "important"); return modal; } function showModal(title, content, onConfirm) { const modal = createModalContainer(); modal.innerHTML = ` <div class="cms-modal-content"> <h3>${title}</h3> <div class="cms-modal-body">${content}</div> <div class="cms-modal-buttons"> <button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button> ${onConfirm ? '<button class="cms-btn cms-btn-primary" data-action="confirm">Confirm</button>' : ""} </div> </div> `; modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove()); if (onConfirm) { modal.querySelector('[data-action="confirm"]').addEventListener("click", () => { onConfirm(); modal.remove(); }); } document.body.appendChild(modal); } class PostAPI { async list() { const res = await fetch("/__list"); const data = await res.json(); return data.posts || []; } async create(title, slug, frontmatter = {}) { const res = await fetch("/__create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, slug, frontmatter }) }); return res.json(); } async delete(path) { const res = await fetch("/__delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }) }); return res.json(); } async getFrontmatter(path) { const res = await fetch("/__get-frontmatter", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }) }); const data = await res.json(); return data.frontmatter || {}; } async updateFrontmatter(path, frontmatter) { await fetch("/__update-frontmatter", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, frontmatter }) }); } async getSchema() { const res = await fetch("/__schema"); const data = await res.json(); return data.schema || {}; } } function setupImagePlugin(editor) { const root = editor.root; const insertAtPoint = (e, node) => { const anyDoc = document; let range = null; if (anyDoc.caretRangeFromPoint) range = anyDoc.caretRangeFromPoint(e.clientX, e.clientY); else if (anyDoc.caretPositionFromPoint) { const pos = anyDoc.caretPositionFromPoint(e.clientX, e.clientY); if (pos) { range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); } } if (range) { range.collapse(true); range.insertNode(node); } else { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const r = sel.getRangeAt(0); r.deleteContents(); r.insertNode(node); } else { root.appendChild(node); } } }; const handleDrop = async (e) => { e.preventDefault(); root.classList.remove("cms-drag-over"); const files = Array.from(e.dataTransfer?.files || []).filter((f) => f.type.startsWith("image/")); if (files.length > 0) { for (const file of files) await uploadImage(file, file.name, e); return; } const uri = e.dataTransfer?.getData("text/uri-list") || ""; if (uri && /^https?:\/\//.test(uri)) { try { const res = await fetch(uri, { mode: "cors" }); const blob = await res.blob(); const name = uri.split("/").pop() || "image"; await uploadImage(blob, name, e); return; } catch { editor.updateStatus({ state: "error", text: "❌ Image fetch failed" }); } } }; const uploadImage = async (fileOrBlob, suggestedName, e) => { editor.updateStatus({ state: "saving", text: "\uD83D\uDCC1 Uploading..." }); const formData = new FormData; let file; if (fileOrBlob instanceof File) file = fileOrBlob; else file = new File([fileOrBlob], suggestedName || "image.png", { type: fileOrBlob.type || "image/png" }); formData.append("file", file); try { const res = await fetch("/__upload", { method: "POST", body: formData }); const { url } = await res.json(); const img = document.createElement("img"); img.src = url; img.alt = file.name; img.style.maxWidth = "100%"; if (e) insertAtPoint(e, img); else { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(img); } else { root.appendChild(img); } } editor.updateStatus({ state: "saved", text: "✅ Uploaded" }); setTimeout(() => editor.updateStatus({ state: "editing", text: "✏️ Editing" }), 1500); editor.debouncedSave(); } catch (err) { editor.updateStatus({ state: "error", text: "❌ Upload failed" }); } }; root.addEventListener("dragover", (e) => { e.preventDefault(); root.classList.add("cms-drag-over"); }); root.addEventListener("dragleave", () => root.classList.remove("cms-drag-over")); root.addEventListener("drop", handleDrop); root.addEventListener("paste", (e) => { const item = Array.from(e.clipboardData?.items || []).find((i) => i.type.startsWith("image/")); if (item) { e.preventDefault(); const file = item.getAsFile(); if (file) uploadImage(file); } }); } function setupPostManagement() { if (document.querySelector(".cms-sidebar")) return; const api = new PostAPI; const sidebar = document.createElement("div"); sidebar.className = "cms-sidebar"; try { sidebar.setAttribute("transition:persist", "inlinecms-sidebar"); } catch {} sidebar.innerHTML = ` <div class="cms-sidebar-header"> <div class="cms-brand"><span class="cms-brand-dot"></span>InlineCMS</div> <div class="cms-grip" aria-hidden>⋮⋮</div> </div> <div class="cms-sidebar-group"> <button class="cms-sidebar-btn" data-action="new"><span class="cms-ico">+</span><span>New</span></button> <button class="cms-sidebar-btn" data-action="list"><span class="cms-ico">☰</span><span>All Posts</span></button> <button class="cms-sidebar-btn" data-action="frontmatter"><span class="cms-ico">⚙︎</span><span>Settings</span></button> </div> <div class="cms-sidebar-sep"></div> <button class="cms-sidebar-btn cms-btn-danger" data-action="delete"><span class="cms-ico">✕</span><span>Delete</span></button> `; document.body.appendChild(sidebar); try { const saved = localStorage.getItem("cms-sidebar-pos"); if (saved) { const { left, top } = JSON.parse(saved); if (typeof left === "number" && typeof top === "number") { sidebar.style.left = `${left}px`; sidebar.style.top = `${top}px`; sidebar.style.transform = ""; } } } catch {} const handleNew = () => { const modal = createModalContainer(); modal.innerHTML = ` <div class="cms-modal-content"> <h3>Create New Post</h3> <div class="cms-modal-body"> <label>Title: <input type="text" id="post-title" style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;"></label> <label style="margin-top: 12px; display: block;">Slug: <input type="text" id="post-slug" style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;"></label> </div> <div class="cms-modal-buttons"> <button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button> <button class="cms-btn cms-btn-primary" data-action="create">Create</button> </div> </div> `; modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove()); modal.querySelector('[data-action="create"]').addEventListener("click", async () => { const title = document.getElementById("post-title").value; const slug = document.getElementById("post-slug").value; if (!title || !slug) return; const result = await api.create(title, slug); if (result.success && result.path) { showToast("success", "Created", "Post created successfully"); window.location.href = result.path; } else { showToast("error", "Error", result.error || "Failed to create post"); } modal.remove(); }); document.body.appendChild(modal); }; const handleList = async () => { const posts = await api.list(); const modal = createModalContainer(); modal.innerHTML = ` <div class="cms-modal-content" style="max-width: 700px;"> <h3>All Posts (${posts.length})</h3> <div class="cms-modal-body" style="max-height: 400px; overflow-y: auto;"> ${posts.map((p) => ` <div style="padding: 12px; border-bottom: 1px solid #eee; cursor: pointer; display: flex; justify-content: space-between; align-items: center;" onclick="window.location.href='${p.path}'"> <div> <b>${p.title}</b> <div style="font-size: 12px; color: #666;">${p.slug} • ${p.draft ? "Draft" : "Published"}</div> </div> <div style="font-size: 12px; color: #999;">${p.date}</div> </div> `).join("")} </div> <div class="cms-modal-buttons"> <button class="cms-btn cms-btn-secondary" data-action="close">Close</button> </div> </div> `; modal.querySelector('[data-action="close"]').addEventListener("click", () => modal.remove()); document.body.appendChild(modal); }; const handleFrontmatter = async () => { const fm = await api.getFrontmatter(location.pathname); const schema = await api.getSchema(); const modal = createModalContainer(); const fields = Object.entries(fm).map(([key, val]) => ` <label style="display: block; margin-bottom: 12px;"> ${key}: <input type="${schema[key] === "date" ? "date" : "text"}" data-key="${key}" value="${val}" style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;"> </label> `).join(""); modal.innerHTML = ` <div class="cms-modal-content"> <h3>Edit Frontmatter</h3> <div class="cms-modal-body" style="max-height: 400px; overflow-y: auto;">${fields}</div> <div class="cms-modal-buttons"> <button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button> <button class="cms-btn cms-btn-primary" data-action="save">Save</button> </div> </div> `; modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove()); modal.querySelector('[data-action="save"]').addEventListener("click", async () => { const inputs = modal.querySelectorAll("input[data-key]"); const newFm = {}; inputs.forEach((input) => { newFm[input.dataset.key] = input.value; }); await api.updateFrontmatter(location.pathname, newFm); showToast("success", "Saved", "Frontmatter updated"); modal.remove(); setTimeout(() => location.reload(), 1000); }); document.body.appendChild(modal); }; const handleDelete = async () => { showModal("Delete Post?", "This action cannot be undone.", async () => { await api.delete(location.pathname); showToast("success", "Deleted", "Post deleted"); setTimeout(() => window.location.href = "/", 1500); }); }; sidebar.querySelector('[data-action="new"]').addEventListener("click", handleNew); sidebar.querySelector('[data-action="list"]').addEventListener("click", handleList); sidebar.querySelector('[data-action="frontmatter"]').addEventListener("click", handleFrontmatter); sidebar.querySelector('[data-action="delete"]').addEventListener("click", handleDelete); const header = sidebar.querySelector(".cms-sidebar-header"); let dragging = false; let startX = 0, startY = 0; let startLeft = 0, startTop = 0; const onPointerDown = (e) => { if (e.button !== 0 && e.pointerType !== "touch") return; const rect = sidebar.getBoundingClientRect(); dragging = true; startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; header.setPointerCapture?.(e.pointerId); sidebar.classList.add("cms-sidebar-dragging"); document.body.style.userSelect = "none"; }; const onPointerMove = (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const rect = sidebar.getBoundingClientRect(); const width = rect.width; const height = rect.height; const vw = window.innerWidth; const vh = window.innerHeight; let left = startLeft + dx; let top = startTop + dy; left = Math.max(8, Math.min(vw - width - 8, left)); top = Math.max(8, Math.min(vh - height - 8, top)); sidebar.style.left = `${left}px`; sidebar.style.top = `${top}px`; sidebar.style.transform = ""; }; const endDrag = (e) => { if (!dragging) return; dragging = false; try { e && header.releasePointerCapture?.(e.pointerId); } catch {} sidebar.classList.remove("cms-sidebar-dragging"); document.body.style.userSelect = ""; const left = parseFloat(sidebar.style.left || "20"); const top = parseFloat(sidebar.style.top || "0"); try { localStorage.setItem("cms-sidebar-pos", JSON.stringify({ left, top })); } catch {} }; header.addEventListener("pointerdown", onPointerDown); window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", endDrag); document.addEventListener("keydown", (e) => { if (e.ctrlKey && e.key === "n" && !e.shiftKey) { e.preventDefault(); handleNew(); } if (e.ctrlKey && e.shiftKey && e.key === "L") { e.preventDefault(); handleList(); } if (e.ctrlKey && e.shiftKey && e.key === "F") { e.preventDefault(); handleFrontmatter(); } if (e.ctrlKey && e.shiftKey && e.key === "D") { e.preventDefault(); handleDelete(); } }); if (!document.querySelector("#cms-sidebar-styles")) { const style = document.createElement("style"); style.id = "cms-sidebar-styles"; style.textContent = ` :root { --cms-accent: #6366f1; } .cms-sidebar { position: fixed; left: 20px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 10px; min-width: 200px; z-index: 9998; padding: 14px; border-radius: 16px; background: rgba(255,255,255,0.75); border: 1px solid rgba(100,116,139,0.18); box-shadow: 0 12px 30px rgba(2,6,23,0.12); backdrop-filter: blur(12px) saturate(1.2); } @media (prefers-color-scheme: dark) { .cms-sidebar { background: rgba(15,23,42,0.55); border-color: rgba(148,163,184,0.18); box-shadow: 0 12px 34px rgba(0,0,0,0.45); } } .cms-sidebar:hover { box-shadow: 0 16px 36px rgba(2,6,23,0.16); } .cms-sidebar-header { display: flex; align-items: center; justify-content: space-between; cursor: grab; user-select: none; } .cms-sidebar-dragging .cms-sidebar-header { cursor: grabbing; } .cms-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14px; letter-spacing: .2px; } .cms-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: linear-gradient(135deg, var(--cms-accent), #22d3ee); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); } .cms-grip { font-size: 14px; color: #94a3b8; letter-spacing: 2px; } @media (prefers-color-scheme: dark) { .cms-grip { color: #64748b; } } .cms-sidebar-group { display: flex; flex-direction: column; gap: 8px; } .cms-sidebar-sep { height: 1px; background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent); margin: 2px 2px 0; } .cms-sidebar-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid transparent; background: linear-gradient(180deg, rgba(241,245,249,0.8), rgba(241,245,249,0.6)); color: #0f172a; font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .08s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; text-align: left; } .cms-sidebar-btn:hover { transform: translateY(-1px); background: linear-gradient(180deg, rgba(226,232,240,0.9), rgba(226,232,240,0.7)); border-color: rgba(99,102,241,0.35); box-shadow: 0 6px 14px rgba(2,6,23,0.06); } .cms-sidebar-btn:active { transform: translateY(0); } .cms-ico { display:inline-flex; align-items:center; justify-content:center; width: 22px; height: 22px; border-radius: 7px; background: linear-gradient(135deg, rgba(99,102,241,0.18), rgba(99,102,241,0.08)); color: var(--cms-accent); font-size: 12px; } @media (prefers-color-scheme: dark) { .cms-sidebar-btn { background: linear-gradient(180deg, rgba(30,41,59,0.9), rgba(30,41,59,0.7)); color: #e2e8f0; } .cms-sidebar-btn:hover { background: linear-gradient(180deg, rgba(51,65,85,0.95), rgba(30,41,59,0.8)); } } .cms-btn-danger { background: linear-gradient(180deg, rgba(254,226,226,0.9), rgba(254,226,226,0.7)) !important; color: #b91c1c; } .cms-btn-danger:hover { background: linear-gradient(180deg, rgba(254,202,202,0.95), rgba(254,202,202,0.8)) !important; border-color: rgba(239,68,68,0.35); } @media (prefers-color-scheme: dark) { .cms-btn-danger { background: linear-gradient(180deg, rgba(127,29,29,0.65), rgba(69,10,10,0.65)) !important; color: #fecaca; } } .cms-drag-over { border-color: var(--cms-accent) !important; background: rgba(99,102,241,0.08) !important; } `; document.head.appendChild(style); } } // src/inlinecms.ts function getCurrentElement() { const sel = window.getSelection(); return sel?.anchorNode ?? null; } function insertText(text) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } function getCursorPos(el) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0); const preRange = range.cloneRange(); preRange.selectNodeContents(el); preRange.setEnd(range.endContainer, range.endOffset); return preRange.toString().length; } function setCursorPos(el, pos) { const sel = window.getSelection(); if (!sel) return; let charCount = 0; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let node; while (node = walker.nextNode()) { const len = node.textContent?.length || 0; if (charCount + len >= pos) { const range2 = document.createRange(); range2.setStart(node, pos - charCount); range2.collapse(true); sel.removeAllRanges(); sel.addRange(range2); return; } charCount += len; } const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } class InlineCMS { root; config; isEditing = false; saveTimeout = null; originalContent = ""; hasUnsavedChanges = false; history = []; historyIndex = -1; historyTimeout = null; statusEl; statusText; MAX_RETRIES = 3; AUTO_SAVE_DELAY = 2000; HISTORY_SIZE = 50; constructor(root, config) { this.root = root; this.config = config; this.statusEl = this.createStatus(); this.statusText = this.statusEl.querySelector(".status-text"); this.init(); } init() { this.root.contentEditable = "true"; this.root.style.outline = "none"; this.addStyles(); this.originalContent = this.root.innerHTML; this.saveHistory(); this.root.addEventListener("focus", () => this.onFocus()); this.root.addEventListener("blur", () => this.onBlur()); this.root.addEventListener("input", () => this.onInput()); this.root.addEventListener("keydown", (e) => this.onKey(e)); this.setupPlugins(); } onFocus() { this.isEditing = true; this.root.classList.add("editing"); this.updateStatus("editing", this.hasUnsavedChanges ? "✏️ Editing (unsaved)" : "✏️ Editing"); } onBlur() { this.isEditing = false; this.root.classList.remove("editing"); this.updateStatus("idle", this.hasUnsavedChanges ? "⚠️ Unsaved" : ""); if (this.hasUnsavedChanges) { if (this.saveTimeout) clearTimeout(this.saveTimeout); this.save(); } } onInput() { this.checkChanges(); this.updateStatus("typing", "✏️ Typing..."); this.saveHistory(); this.debounceSave(); } onKey(e) { if (e.ctrlKey || e.metaKey) { if (e.key === "s") { e.preventDefault(); this.save(); return true; } if (e.key === "z" && !e.shiftKey) { e.preventDefault(); this.undo(); return true; } if (e.key === "z" && e.shiftKey) { e.preventDefault(); this.redo(); return true; } if (e.key === "y") { e.preventDefault(); this.redo(); return true; } if (e.key === "b") { e.preventDefault(); document.execCommand("bold"); return true; } if (e.key === "i") { e.preventDefault(); document.execCommand("italic"); return true; } if (e.key === "k") { e.preventDefault(); this.createLink(); return true; } } const el = getCurrentElement(); if (e.key === "Backspace" && window.getSelection()?.anchorOffset === 0) { const h = el?.parentElement?.closest("h1,h2,h3,h4,h5,h6"); if (h) { e.preventDefault(); const level = parseInt(h.tagName[1]); const content = h.innerHTML; const newTag = level === 1 ? "p" : `h${level - 1}`; const newEl = document.createElement(newTag); newEl.innerHTML = content; h.replaceWith(newEl); if (newEl.firstChild) { const range = document.createRange(); range.setStart(newEl.firstChild, 0); range.collapse(true); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); } this.debounceSave(); return true; } } if (e.key === "Tab") { const li = this.findLI(el); if (li) { e.preventDefault(); e.shiftKey ? this.outdentLI(li) : this.indentLI(li); this.debounceSave(); return true; } } const code = this.findCode(el); if (code) { if (e.key === "Enter") { e.preventDefault(); insertText(` `); return true; } if (e.key === "Tab") { e.preventDefault(); e.shiftKey ? this.unindentCode(code) : insertText(" "); return true; } if (e.key.length === 1 || e.key === "Delete" || e.key === "Backspace") { setTimeout(() => this.cleanCode(code), 0); } } return false; } saveHistory(immediate = false) { if (this.historyTimeout) clearTimeout(this.historyTimeout); const doSave = () => { const content = this.root.innerHTML; const cur = this.history[this.historyIndex]; if (cur && cur.content === content) return; this.history = this.history.slice(0, this.historyIndex + 1); this.history.push({ content, timestamp: Date.now(), cursorPosition: getCursorPos(this.root) }); this.historyIndex++; if (this.history.length > this.HISTORY_SIZE) { this.history.shift(); this.historyIndex--; } }; immediate ? doSave() : this.historyTimeout = window.setTimeout(doSave, 300); } undo() { if (this.historyIndex <= 0) return; this.historyIndex--; const entry = this.history[this.historyIndex]; this.root.innerHTML = entry.content; setCursorPos(this.root, entry.cursorPosition); this.debounceSave(); this.updateStatus("editing", "↶ Undone"); setTimeout(() => this.updateStatus("editing", "✏️ Editing"), 1500); } redo() { if (this.historyIndex >= this.history.length - 1) return; this.historyIndex++; const entry = this.history[this.historyIndex]; this.root.innerHTML = entry.content; setCursorPos(this.root, entry.cursorPosition); this.debounceSave(); this.updateStatus("editing", "↷ Redone"); setTimeout(() => this.updateStatus("editing", "✏️ Editing"), 1500); } checkChanges() { this.hasUnsavedChanges = this.root.innerHTML !== this.originalContent; if (this.hasUnsavedChanges) { window.addEventListener("beforeunload", this.beforeUnload); } else { window.removeEventListener("beforeunload", this.beforeUnload); } } beforeUnload = (e) => { if (this.hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; return ""; } }; updateStatus(state, text) { this.statusText.textContent = text; const warn = this.hasUnsavedChanges && (state === "idle" || state === "editing"); this.statusEl.className = warn ? "cms-status cms-status-warning" : `cms-status cms-status-${state}`; this.statusEl.style.display = state === "idle" && !this.hasUnsavedChanges ? "none" : "block"; } debouncedSave() { this.debounceSave(); } debounceSave() { if (this.saveTimeout) clearTimeout(this.saveTimeout); this.saveTimeout = window.setTimeout(() => this.save(), this.AUTO_SAVE_DELAY); } async save(retry = 0) { this.updateStatus("saving", retry > 0 ? `\uD83D\uDCBE Retry ${retry}/${this.MAX_RETRIES}` : "\uD83D\uDCBE Saving..."); try { const res = await fetch("/__save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: location.pathname, html: this.root.innerHTML }) }); if (res.ok) { this.originalContent = this.root.innerHTML; this.hasUnsavedChanges = false; window.removeEventListener("beforeunload", this.beforeUnload); this.updateStatus("saved", "✅ Saved"); setTimeout(() => { const state = this.isEditing ? "editing" : "idle"; const text = this.isEditing ? "✏️ Editing" : ""; this.updateStatus(state, text); }, 1500); } else if (retry < this.MAX_RETRIES) { setTimeout(() => this.save(retry + 1), Math.pow(2, retry) * 1000); } else { this.updateStatus("error", "❌ Failed"); this.showRetryDialog(); } } catch (err) { if (retry < this.MAX_RETRIES) { setTimeout(() => this.save(retry + 1), Math.pow(2, retry) * 1000); } else { this.updateStatus("error", "❌ Failed"); this.showRetryDialog(); } } } findLI(node) { while (node) { if (node.nodeType === Node.ELEMENT_NODE && node.tagName === "LI") { return node; } const li = node.closest?.("li"); if (li) return li; node = node.parentNode; } return null; } indentLI(li) { const prev = li.previousElementSibling; if (!prev) return; const parent = li.parentElement; let nested = prev.querySelector(":scope > ul, :scope > ol"); if (!nested) { nested = document.createElement(parent.tagName.toLowerCase()); prev.appendChild(nested); } nested.appendChild(li); } outdentLI(li) { const parent = li.parentElement; const grandLI = parent.parentElement?.closest("li"); if (!grandLI) return; const greatList = grandLI.parentElement; greatList.insertBefore(li, grandLI.nextSibling); if (parent.children.length === 0) parent.remove(); } findCode(node) { while (node) { if (node.nodeType === Node.ELEMENT_NODE) { const el = node; if (el.tagName === "CODE" && el.closest("pre")) return el; } node = node.parentNode; } return null; } cleanCode(code) { const pos = this.getCursorOffsetIn(code); const txt = code.textContent || ""; code.innerHTML = ""; code.appendChild(document.createTextNode(txt)); this.setCursorOffsetIn(code, pos); } unindentCode(code) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const text = range.startContainer.textContent || ""; const offset = range.startOffset; let start = offset; while (start > 0 && text[start - 1] !== ` `) start--; let spaces = 0; for (let i = start;i < text.length && i < start + 4; i++) { if (text[i] === " ") spaces++; else break; } if (spaces > 0) { range.startContainer.textContent = text.substring(0, start) + text.substring(start + spaces); range.setStart(range.startContainer, Math.max(start, offset - spaces)); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } } getCursorOffsetIn(el) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0); let offset = 0; if (range.startContainer.nodeType === Node.TEXT_NODE) { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let node; while (node = walker.nextNode()) { if (node === range.startContainer) { offset += range.startOffset; break; } offset += node.textContent?.length || 0; } } return offset; } setCursorOffsetIn(el, offset) { const sel = window.getSelection(); if (!sel) return; const text = el.firstChild; if (text && text.nodeType === Node.TEXT_NODE) { const range = document.createRange(); const safe = Math.min(offset, text.textContent?.length || 0); range.setStart(text, safe); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } } setupPlugins() { const math = this.root.querySelectorAll(".katex, .MathJax"); math.forEach((m) => { const el = m; el.contentEditable = "false"; el.style.cursor = "pointer"; el.onclick = () => this.editMath(el); }); new MutationObserver((muts) => { muts.forEach((m) => { m.addedNodes.forEach((n) => { if (n.nodeType === Node.ELEMENT_NODE) { const el = n; if (el.classList?.contains("katex") || el.classList?.contains("MathJax")) { el.contentEditable = "false"; el.style.cursor = "pointer"; el.onclick = () => this.editMath(el); } } }); }); }).observe(this.root, { childList: true, subtree: true }); Promise.resolve().then(() => exports_plugins).then(({ setupImagePlugin: setupImagePlugin2, setupPostManagement: setupPostManagement2 }) => { setupImagePlugin2(this); setupPostManagement2(); }); } editMath(el) { const ann = el.querySelector('annotation[encoding="application/x-tex"]'); const src = ann?.textContent || ""; const newLatex = prompt("Edit LaTeX:", src); if (newLatex === null || newLatex === src) return; const isDisplay = el.closest(".katex-display") !== null; const delim = isDisplay ? "$$" : "$"; el.replaceWith(document.createTextNode(`${delim}${newLatex}${delim}`)); this.debounceSave(); } createLink() { const sel = window.getSelection(); if (!sel) return; const text = sel.toString(); const url = prompt("URL:", "https://"); if (!url) return; if (text) { document.execCommand("createLink", false, url); } else { const linkText = prompt("Link text:", url); if (!linkText) return; const a = document.createElement("a"); a.href = url; a.textContent = linkText; if (sel.rangeCount > 0) { const range = sel.getRangeAt(0); range.insertNode(a); range.setStartAfter(a); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } } this.debounceSave(); } createStatus() { const el = document.createElement("div"); el.className = "cms-status cms-status-idle"; el.style.display = "none"; el.innerHTML = '<div class="status-main"><span class="status-text"></span></div>'; document.body.appendChild(el); return el; } showRetryDialog() { const dlg = document.createElement("div"); dlg.className = "cms-retry-dialog"; dlg.innerHTML = ` <div class="cms-retry-content"> <h3>⚠️ Save Failed</h3> <p>Unable to save. Check your connection.</p> <div class="cms-retry-buttons"> <button class="cms-retry-btn">Retry</button> <button class="cms-dismiss-btn">Dismiss</button> </div> </div> `; dlg.querySelector(".cms-retry-btn").addEventListener("click", () => { dlg.remove(); this.save(); }); dlg.querySelector(".cms-dismiss-btn").addEventListener("click", () => dlg.remove()); document.body.appendChild(dlg); setTimeout(() => dlg.remove(), 1e4); } addStyles() { const s = document.createElement("style"); s.textContent = ` [data-markdown] { transition: all 0.2s; border: 2px dashed transparent; border-radius: 8px; padding: 16px; margin: -16px; min-height: 50px; } [data-markdown]:hover { border-color: #e2e8f0; background: rgba(59,130,246,0.02); } [data-markdown]:focus, .editing { outline: none; border-color: #3b82f6 !important; background: rgba(59,130,246,0.05) !important; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); } .cms-status { position: fixed; top: 20px; right: 20px; border-radius: 6px; font-size: 12px; font-weight: 500; z-index: 9999; transition: all 0.3s; box-shadow: 0 2px 8px rgba(0,0,0,0.1); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.2); min-width: 100px; } .status-main { display: flex; align-items: center; justify-content: center; padding: 10px 14px; } .cms-status-idle { background: #f1f5f9; color: #64748b; opacity: 0.8; } .cms-status-editing { background: #3b82f6; color: white; } .cms-status-saving { background: #f59e0b; color: white; } .cms-status-saved { background: #10b981; color: white; } .cms-status-typing { background: #6366f1; color: white; } .cms-status-warning { background: #f59e0b; color: white; animation: pulse 2s infinite; } .cms-status-error { background: #ef4444; color: white; } .cms-retry-dialog { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; } .cms-retry-content { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); max-width: 400px; text-align: center; } .cms-retry-content h3 { margin: 0 0 12px 0; font-size: 18px; color: #dc2626; } .cms-retry-content p { margin: 0 0 20px 0; color: #64748b; } .cms-retry-buttons { display: flex; gap: 12px; justify-content: center; } .cms-retry-btn { background: #3b82f6; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; } .cms-retry-btn:hover { background: #2563eb; } .cms-dismiss-btn { background: #f1f5f9; color: #64748b; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; } .cms-dismiss-btn:hover { background: #e2e8f0; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } `; document.head.appendChild(s); } destroy() { if (this.saveTimeout) clearTimeout(this.saveTimeout); if (this.historyTimeout) clearTimeout(this.historyTimeout); window.removeEventListener("beforeunload", this.beforeUnload); this.statusEl.remove(); } } // src/main.ts if (typeof window !== "undefined") { let initTimer = null; const initAll = async () => { const roots = document.querySelectorAll("[data-markdown]"); roots.forEach((root) => { const el = root; if (el.dataset.inlinecmsInitialized === "true") return; el.dataset.inlinecmsInitialized = "true"; new InlineCMS(el, { contentDir: window.__INLINECMS_CONFIG__?.contentDir || "src/content/blog", autosaveDelay: window.__INLINECMS_CONFIG__?.autosaveDelay || 2000, enabled: window.__INLINECMS_CONFIG__?.enabled !== false }); }); try { const mod = await Promise.resolve().then(() => exports_plugins); mod.setupPostManagement?.(); } catch {} }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initAll); } else { initAll(); } document.addEventListener("astro:page-load", () => initAll()); document.addEventListener("astro:after-swap", () => initAll()); document.addEventListener("astro:before-swap", (ev) => { const doc = ev?.newDocument; if (!doc) return; if (!doc.querySelector(".cms-sidebar")) { const placeholder = doc.createElement("div"); placeholder.className = "cms-sidebar"; try { placeholder.setAttribute("transition:persist", "inlinecms-sidebar"); } catch {} placeholder.style.display = "none"; doc.body.appendChild(placeholder); } }); const mo = new MutationObserver(() => { if (initTimer) window.clearTimeout(initTimer); initTimer = window.setTimeout(() => initAll(), 50); }); mo.observe(document.documentElement, { childList: true, subtree: true }); const navEvent = () => { if (initTimer) window.clearTimeout(initTimer); initTimer = window.setTimeout(() => initAll(), 0); }; window.addEventListener("popstate", navEvent); const _ps = history.pushState; history.pushState = function(...args) { const r = _ps.apply(this, args); navEvent(); return r; }; const _rs = history.replaceState; history.replaceState = function(...args) { const r = _rs.apply(this, args); navEvent(); return r; }; } export { InlineCMS };