Files
vvveb-cms/static/js/my-editor.js
2026-05-18 17:23:00 +08:00

1224 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* my-editor.js — VvvebJS 儲存與新增頁面橋接腳本
* 覆蓋 Vvveb.Builder.saveAjax 與 Vvveb.FileManager 行為,無縫串接到 Flask 後端,並引入精美網頁型暗黑系模態框
*/
(function () {
"use strict";
const SLUG = window.VVVEB_PROJECT_SLUG || "";
// ── Toast 通知 ──────────────────────────────────────────────────
function showToast(msg, type) {
const existing = document.getElementById("vvveb-save-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "vvveb-save-toast";
const bg = type === "error" ? "#7f1d1d" : "#14532d";
const border = type === "error" ? "#ef444460" : "#22c55e60";
Object.assign(toast.style, {
position: "fixed",
top: "1rem",
right: "1rem",
zIndex: "99999",
background: bg,
border: "1px solid " + border,
color: "#fff",
borderRadius: "8px",
padding: "0.65rem 1.1rem",
fontSize: "0.85rem",
fontFamily: "Inter, system-ui, sans-serif",
fontWeight: "500",
boxShadow: "0 4px 20px rgba(0,0,0,0.5)",
opacity: "0",
transform: "translateY(-8px)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: "none",
});
toast.textContent = msg;
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
});
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(-8px)";
setTimeout(() => toast.remove(), 350);
}, 3000);
}
// ── 頂級網頁型模態框 (Web-based Custom Dialogs) ──────────────────────
function showModalPrompt(title, defaultValue, callback) {
const existing = document.getElementById("vvveb-custom-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "vvveb-custom-modal";
Object.assign(overlay.style, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
zIndex: "100000",
background: "rgba(10, 11, 18, 0.65)",
backdropFilter: "blur(12px)",
webkitBackdropFilter: "blur(12px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: "0",
transition: "opacity 0.25s ease-out"
});
const card = document.createElement("div");
Object.assign(card.style, {
background: "rgba(22, 24, 38, 0.95)",
border: "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.6), 0 0 40px rgba(99, 102, 241, 0.1)",
borderRadius: "16px",
width: "420px",
padding: "2rem",
color: "#f8fafc",
fontFamily: "Inter, system-ui, sans-serif",
transform: "scale(0.95)",
transition: "transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)",
display: "flex",
flexDirection: "column",
gap: "1.25rem"
});
card.innerHTML = `
<div style="font-size: 1.15rem; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 0.6rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
${title}
</div>
<div>
<input type="text" id="vvveb-modal-input" style="
width: 100%;
background: rgba(13, 14, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.65rem 0.9rem;
color: #fff;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
" />
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 0.5rem;">
<button id="vvveb-modal-cancel" style="
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.55rem 1.1rem;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s;
">取消</button>
<button id="vvveb-modal-confirm" style="
background: #4f46e5;
border: none;
border-radius: 8px;
padding: 0.55rem 1.25rem;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
">確定</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const input = card.querySelector("#vvveb-modal-input");
input.value = defaultValue;
input.addEventListener("focus", () => {
input.style.borderColor = "#818cf8";
input.style.boxShadow = "0 0 12px rgba(129, 140, 248, 0.25)";
});
input.addEventListener("blur", () => {
input.style.borderColor = "rgba(255, 255, 255, 0.1)";
input.style.boxShadow = "none";
});
const cancelBtn = card.querySelector("#vvveb-modal-cancel");
cancelBtn.addEventListener("mouseover", () => {
cancelBtn.style.background = "rgba(255, 255, 255, 0.05)";
cancelBtn.style.color = "#fff";
});
cancelBtn.addEventListener("mouseout", () => {
cancelBtn.style.background = "transparent";
cancelBtn.style.color = "#94a3b8";
});
const confirmBtn = card.querySelector("#vvveb-modal-confirm");
confirmBtn.addEventListener("mouseover", () => {
confirmBtn.style.background = "#4338ca";
});
confirmBtn.addEventListener("mouseout", () => {
confirmBtn.style.background = "#4f46e5";
});
// Fade in
setTimeout(() => {
overlay.style.opacity = "1";
card.style.transform = "scale(1)";
}, 10);
input.focus();
input.select();
function close(value) {
overlay.style.opacity = "0";
card.style.transform = "scale(0.95)";
setTimeout(() => {
overlay.remove();
if (value !== undefined) {
const finalVal = value.trim ? value.trim() : value;
callback(finalVal);
}
}, 250);
}
cancelBtn.onclick = () => close();
confirmBtn.onclick = () => close(input.value);
input.onkeydown = (e) => {
if (e.key === "Enter") {
close(input.value);
} else if (e.key === "Escape") {
close();
}
};
}
function showModalConfirm(title, message, callback) {
const existing = document.getElementById("vvveb-custom-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "vvveb-custom-modal";
Object.assign(overlay.style, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
zIndex: "100000",
background: "rgba(10, 11, 18, 0.65)",
backdropFilter: "blur(12px)",
webkitBackdropFilter: "blur(12px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: "0",
transition: "opacity 0.25s ease-out"
});
const card = document.createElement("div");
Object.assign(card.style, {
background: "rgba(22, 24, 38, 0.95)",
border: "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.6), 0 0 40px rgba(239, 68, 68, 0.1)",
borderRadius: "16px",
width: "420px",
padding: "2rem",
color: "#f8fafc",
fontFamily: "Inter, system-ui, sans-serif",
transform: "scale(0.95)",
transition: "transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)",
display: "flex",
flexDirection: "column",
gap: "1.25rem"
});
card.innerHTML = `
<div style="font-size: 1.15rem; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 0.6rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
${title}
</div>
<div style="font-size: 0.9rem; color: #cbd5e1; line-height: 1.5;">
${message}
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 0.5rem;">
<button id="vvveb-modal-cancel" style="
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.55rem 1.1rem;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s;
">取消</button>
<button id="vvveb-modal-confirm" style="
background: #ef4444;
border: none;
border-radius: 8px;
padding: 0.55rem 1.25rem;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
">確定</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const cancelBtn = card.querySelector("#vvveb-modal-cancel");
cancelBtn.addEventListener("mouseover", () => {
cancelBtn.style.background = "rgba(255, 255, 255, 0.05)";
cancelBtn.style.color = "#fff";
});
cancelBtn.addEventListener("mouseout", () => {
cancelBtn.style.background = "transparent";
cancelBtn.style.color = "#94a3b8";
});
const confirmBtn = card.querySelector("#vvveb-modal-confirm");
confirmBtn.addEventListener("mouseover", () => {
confirmBtn.style.background = "#dc2626";
});
confirmBtn.addEventListener("mouseout", () => {
confirmBtn.style.background = "#ef4444";
});
// Fade in
setTimeout(() => {
overlay.style.opacity = "1";
card.style.transform = "scale(1)";
}, 10);
confirmBtn.focus();
function close(confirmed) {
overlay.style.opacity = "0";
card.style.transform = "scale(0.95)";
setTimeout(() => {
overlay.remove();
if (confirmed) callback();
}, 250);
}
cancelBtn.onclick = () => close(false);
confirmBtn.onclick = () => close(true);
const keyHandler = (e) => {
if (e.key === "Escape") {
close(false);
window.removeEventListener("keydown", keyHandler);
}
};
window.addEventListener("keydown", keyHandler);
}
// ── 三欄位頁面表單對話框 (頁面名稱 / 檔案名稱 / 資料夾) ────────────
function showModalPageForm(actionTitle, defaultTitle, defaultFile, defaultFolder, callback) {
const existing = document.getElementById("vvveb-custom-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "vvveb-custom-modal";
Object.assign(overlay.style, {
position: "fixed", top: "0", left: "0", width: "100%", height: "100%",
zIndex: "100000", background: "rgba(10,11,18,0.65)",
backdropFilter: "blur(12px)", display: "flex",
alignItems: "center", justifyContent: "center",
opacity: "0", transition: "opacity 0.25s ease-out"
});
const card = document.createElement("div");
Object.assign(card.style, {
background: "rgba(22,24,38,0.97)",
border: "1px solid rgba(255,255,255,0.08)",
boxShadow: "0 20px 50px rgba(0,0,0,0.6), 0 0 40px rgba(99,102,241,0.1)",
borderRadius: "16px", width: "460px", padding: "2rem",
color: "#f8fafc", fontFamily: "Inter, system-ui, sans-serif",
transform: "scale(0.95)",
transition: "transform 0.25s cubic-bezier(0.34,1.56,0.64,1)",
display: "flex", flexDirection: "column", gap: "1.1rem"
});
const fieldStyle = `
width:100%; background:rgba(13,14,24,0.8);
border:1px solid rgba(255,255,255,0.1); border-radius:8px;
padding:0.6rem 0.85rem; color:#fff; font-size:0.88rem;
outline:none; box-sizing:border-box; transition:border-color 0.2s, box-shadow 0.2s;`;
const labelStyle = `display:block; font-size:0.78rem; color:#94a3b8; margin-bottom:0.3rem; font-weight:500;`;
card.innerHTML = `
<div style="font-size:1.1rem; font-weight:600; color:#fff; display:flex; align-items:center; gap:0.55rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
${actionTitle}
</div>
<div>
<label style="${labelStyle}">頁面名稱(顯示用)</label>
<input id="pf-title" type="text" style="${fieldStyle}" value="${defaultTitle}" placeholder="我的頁面" />
</div>
<div>
<label style="${labelStyle}">檔案名稱(.html 副檔名可省略)</label>
<input id="pf-file" type="text" style="${fieldStyle}" value="${defaultFile}" placeholder="my-page" />
</div>
<div>
<label style="${labelStyle}">儲存至資料夾(留空則存放於根目錄)</label>
<input id="pf-folder" type="text" style="${fieldStyle}" value="${defaultFolder}" placeholder="留空 = 根目錄" />
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:0.4rem;">
<button id="pf-cancel" style="background:transparent; border:1px solid rgba(255,255,255,0.1); border-radius:8px; padding:0.5rem 1rem; color:#94a3b8; font-size:0.85rem; cursor:pointer; transition:background 0.2s;">取消</button>
<button id="pf-confirm" style="background:#4f46e5; border:none; border-radius:8px; padding:0.5rem 1.2rem; color:#fff; font-size:0.85rem; font-weight:600; cursor:pointer; box-shadow:0 4px 12px rgba(79,70,229,0.3); transition:background 0.2s;">確定</button>
</div>`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const titleInput = card.querySelector("#pf-title");
const fileInput = card.querySelector("#pf-file");
const folderInput = card.querySelector("#pf-folder");
const cancelBtn = card.querySelector("#pf-cancel");
const confirmBtn = card.querySelector("#pf-confirm");
// Focus style
[titleInput, fileInput, folderInput].forEach(inp => {
inp.addEventListener("focus", () => { inp.style.borderColor = "#818cf8"; inp.style.boxShadow = "0 0 10px rgba(129,140,248,0.2)"; });
inp.addEventListener("blur", () => { inp.style.borderColor = "rgba(255,255,255,0.1)"; inp.style.boxShadow = "none"; });
});
cancelBtn.addEventListener("mouseover", () => { cancelBtn.style.background = "rgba(255,255,255,0.06)"; });
cancelBtn.addEventListener("mouseout", () => { cancelBtn.style.background = "transparent"; });
confirmBtn.addEventListener("mouseover", () => { confirmBtn.style.background = "#4338ca"; });
confirmBtn.addEventListener("mouseout", () => { confirmBtn.style.background = "#4f46e5"; });
// Auto-fill filename from title
titleInput.addEventListener("input", () => {
const slug = titleInput.value.trim().toLowerCase()
.replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "");
if (slug) fileInput.value = slug;
});
setTimeout(() => { overlay.style.opacity = "1"; card.style.transform = "scale(1)"; }, 10);
titleInput.focus();
titleInput.select();
function close(confirmed) {
overlay.style.opacity = "0";
card.style.transform = "scale(0.95)";
setTimeout(() => {
overlay.remove();
if (confirmed) {
callback({ title: titleInput.value.trim(), filename: fileInput.value.trim(), folder: folderInput.value.trim() });
} else {
callback(null);
}
}, 220);
}
cancelBtn.onclick = () => close(false);
confirmBtn.onclick = () => close(true);
fileInput.onkeydown = titleInput.onkeydown = folderInput.onkeydown = (e) => {
if (e.key === "Enter") close(true);
if (e.key === "Escape") close(false);
};
}
// ── 覆蓋 Vvveb.Builder.saveAjax ──────────────────────────────────
function patchSaveAjax() {
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
setTimeout(patchSaveAjax, 200);
return;
}
/**
* VvvebJS 原始簽名: saveAjax(data, saveUrl, callback, error)
*/
Vvveb.Builder.saveAjax = function (data, saveUrl, callback, error) {
if (!SLUG) {
showToast("錯誤:找不到專案識別碼 (slug)", "error");
if (error) error(new Error("Missing slug"));
return;
}
// 確保 data 為物件並填入專案識別碼
if (typeof data !== "object" || data === null) {
data = {};
}
data.slug = SLUG;
// 保留完整相對路徑,不再扁平化處理檔名,以支援子資料夾
// if (data.file) {
// const parts = data.file.split("/");
// data.file = parts[parts.length - 1];
// }
// 如果不是新增頁面(即一般的儲存頁面),且未帶有 HTML 內容,則動態獲取目前 HTML
const isNewPage = !!data.startTemplateUrl;
if (!isNewPage && !data.html) {
try {
data.html = Vvveb.Builder.getHtml ? Vvveb.Builder.getHtml() : "";
} catch (e) {
console.error("[my-editor] getHtml failed:", e);
}
}
// 顯示儲存中狀態
const saveBtn = document.querySelector(".save-btn");
if (saveBtn && !isNewPage) {
saveBtn.querySelector(".loading")?.classList.remove("d-none");
saveBtn.querySelector(".button-text")?.classList.add("d-none");
}
// 發送 POST 請求至 Flask API
fetch("/api/save", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(data).toString(),
})
.then((res) => {
if (!res.ok) {
return res.json().then((errData) => {
throw new Error(errData.error || "伺服器儲存錯誤");
});
}
return res.json();
})
.then((resData) => {
if (isNewPage) {
// 新增頁面成功:秀出通知並執行 Vvveb 原生 callback 以載入新頁面
showToast("✓ 成功建立新頁面:" + resData.file);
if (callback) {
callback({
name: resData.name,
title: resData.title,
file: resData.file,
url: resData.url
});
}
} else {
// 一般儲存成功
showToast("✓ 已儲存:" + (resData.saved || data.file));
if (callback) callback(resData);
// 停用儲存按鈕(直至下一次變更)
document.querySelectorAll("#top-panel .save-btn").forEach((e) =>
e.setAttribute("disabled", "true")
);
}
})
.catch((err) => {
showToast("儲存失敗:" + err.message, "error");
if (error) error(err);
})
.finally(() => {
if (saveBtn && !isNewPage) {
saveBtn.querySelector(".loading")?.classList.add("d-none");
saveBtn.querySelector(".button-text")?.classList.remove("d-none");
}
});
};
console.log("[my-editor] Robust Vvveb.Builder.saveAjax patched for slug:", SLUG);
}
// ── 覆蓋 Vvveb.FileManager 頁面操作 ──────────────────────────────
function patchFileManager() {
if (typeof Vvveb === "undefined" || !Vvveb.FileManager) {
setTimeout(patchFileManager, 200);
return;
}
// 覆蓋 init 方法以修正 VvvebJS 使用全球廢棄的 `event.target` 導致在某些瀏覽器/嚴格模式下點擊無效的 Bug
const originalInit = Vvveb.FileManager.init;
Vvveb.FileManager.init = function (allowedComponents = {}) {
originalInit.call(this, allowedComponents);
// 利用 Clone 移除舊有包含 Bug 的匿名字選區接聽器
const newTree = this.tree.cloneNode(true);
this.tree.parentNode.replaceChild(newTree, this.tree);
this.tree = newTree;
// 綁定符合標準且高度相容 (e.target) 的新接聽器
this.tree.addEventListener("click", function (e) {
let element = e.target.closest("a");
if (element) {
e.stopImmediatePropagation();
if (element.classList.contains('view')) return;
e.preventDefault();
return false;
}
});
this.tree.addEventListener("click", function (e) {
let element = e.target.closest(".delete");
if (element) {
Vvveb.FileManager.deletePage(element.closest("li"), e);
e.stopImmediatePropagation();
e.preventDefault();
return false;
}
});
this.tree.addEventListener("click", function (e) {
let element = e.target.closest(".rename");
if (element) {
Vvveb.FileManager.renamePage(element.closest("li"), e, false);
e.stopImmediatePropagation();
e.preventDefault();
return false;
}
});
this.tree.addEventListener("click", function (e) {
let element = e.target.closest(".duplicate");
if (element) {
Vvveb.FileManager.renamePage(element.closest("li"), e, true);
e.stopImmediatePropagation();
e.preventDefault();
return false;
}
});
this.tree.addEventListener("click", function (e) {
let element = e.target.closest("li[data-page] label");
if (element) {
let page = element.parentNode.dataset.page;
if (page) {
Vvveb.FileManager.loadPage(page);
e.stopImmediatePropagation();
e.preventDefault();
return false;
}
}
});
};
// 覆蓋重新命名 / 複製頁面(三欄位對話框:頁面名稱、檔案名稱、儲存至資料夾)
Vvveb.FileManager.renamePage = function (element, e, duplicate = false) {
let page = element.dataset;
const currentTitle = element.querySelector("label > span")?.textContent?.trim() || page.file.replace(".html","");
const currentFile = page.file.replace(".html","");
const action = duplicate ? "複製頁面" : "重新命名頁面";
showModalPageForm(action, currentTitle, currentFile, "", function (formData) {
if (!formData) return;
let { title, filename, folder } = formData;
if (!filename) return;
if (!filename.endsWith(".html")) filename += ".html";
const bodyData = {
slug: SLUG,
file: page.file,
newfile: filename,
title: title,
folder: folder,
duplicate: duplicate ? "true" : "false"
};
fetch(`/api/save?action=rename`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(bodyData).toString()
})
.then((res) => {
if (!res.ok) {
return res.json().then((err) => {
throw new Error(err.error || "操作失敗");
});
}
return res.json();
})
.then((data) => {
showToast(`${data.message}`);
const baseName = data.name || data.newfile.replace('.html', '');
const newTitle = data.title || title || baseName;
if (duplicate) {
let pageData = Object.assign({}, Vvveb.FileManager.pages[page.page]);
pageData["file"] = data.newfile;
pageData["title"] = newTitle;
pageData["url"] = data.url;
pageData["name"] = baseName;
Vvveb.FileManager.addPage(baseName, pageData);
} else {
const oldPageKey = page.page;
Vvveb.FileManager.pages[oldPageKey]["file"] = data.newfile;
Vvveb.FileManager.pages[oldPageKey]["title"] = newTitle;
Vvveb.FileManager.pages[oldPageKey]["url"] = data.url;
Vvveb.FileManager.pages[oldPageKey]["name"] = baseName;
let link = element.querySelector("a.view");
if (link) link.setAttribute("href", data.url);
let span = element.querySelector("label > span") || element.querySelector("span");
if (span) span.textContent = newTitle;
element.dataset.file = data.newfile;
element.dataset.page = baseName;
Vvveb.FileManager.pages[baseName] = Vvveb.FileManager.pages[oldPageKey];
if (baseName !== oldPageKey) {
delete Vvveb.FileManager.pages[oldPageKey];
}
}
})
.catch((err) => {
showToast(`操作失敗:${err.message}`, "error");
});
});
};
// 覆蓋刪除頁面
Vvveb.FileManager.deletePage = function (element, e) {
let page = element.dataset;
if (page.file.toLowerCase() === "index.html") {
showToast("錯誤:無法刪除主頁 index.html", "error");
return;
}
showModalConfirm("確認刪除頁面", `確定要刪除 "${page.file}" 頁面嗎?此動作將無法復原。`, function () {
const bodyData = {
slug: SLUG,
file: page.file
};
fetch(`/api/save?action=delete`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(bodyData).toString()
})
.then((res) => {
if (!res.ok) {
return res.json().then((err) => {
throw new Error(err.error || "刪除失敗");
});
}
return res.json();
})
.then((data) => {
showToast(`${data.message}`);
// 從內部 pages 列表中移除
delete Vvveb.FileManager.pages[page.page];
element.remove();
// 如果刪除的是目前正在編輯的頁面,切換回第一個頁面
if (Vvveb.FileManager.currentPage === page.page) {
let firstPage = Object.keys(Vvveb.FileManager.pages)[0];
if (firstPage) {
Vvveb.FileManager.loadPage(firstPage);
}
}
})
.catch((err) => {
showToast(`刪除失敗:${err.message}`, "error");
});
});
};
console.log("[my-editor] Vvveb.FileManager operations patched.");
}
// ── 覆蓋 Vvveb.NewSection 防止佈局崩潰 ─────────────────────────────
function patchNewSection() {
if (typeof Vvveb === "undefined" || !Vvveb.NewSection) {
setTimeout(patchNewSection, 200);
return;
}
Vvveb.NewSection.insert = function () {
let position = 'before';
let lastSection = Vvveb.Builder.frameBody.querySelector(':scope > footer:last-of-type');
if (!lastSection) {
position = 'after';
lastSection = Vvveb.Builder.frameBody.querySelector(':scope > main:last-of-type') ||
Vvveb.Builder.frameBody.querySelector(':scope > section:last-of-type') ||
Vvveb.Builder.frameBody.querySelector(':scope > div:last-of-type') ||
Vvveb.Builder.frameBody; // 最安全的回退點:整個 body 節點
}
this.container = generateElements(this.template)[0];
if (lastSection === Vvveb.Builder.frameBody) {
Vvveb.Builder.frameBody.appendChild(this.container);
} else {
if (position == 'after') {
lastSection.after(this.container);
} else {
lastSection.before(this.container);
}
}
return this.container;
};
console.log("[my-editor] Vvveb.NewSection.insert patched to prevent layout crashes.");
}
// ── 頁面清單樹狀結構 ────────────────────────────────────────────────
function patchPageTree() {
if (typeof Vvveb === "undefined" || !Vvveb.FileManager) {
setTimeout(patchPageTree, 300);
return;
}
// 攔截 addPages — 批次新增後重組樹狀結構
const origAddPages = Vvveb.FileManager.addPages;
if (origAddPages) {
Vvveb.FileManager.addPages = function (pages) {
origAddPages.call(this, pages);
setTimeout(buildPageTree, 80);
};
}
// 攔截 addPage — 單筆新增Rename/Duplicate後重組
const origAddPage = Vvveb.FileManager.addPage;
if (origAddPage) {
Vvveb.FileManager.addPage = function (name, page, ...rest) {
const result = origAddPage.call(this, name, page, ...rest);
setTimeout(buildPageTree, 80);
return result;
};
}
console.log("[my-editor] Page tree patch ready.");
}
function buildPageTree() {
// 找到頁面清單容器(支援多種可能的 selector
const list = (
document.querySelector("#file-manager .files") ||
document.querySelector(".file-manager .files") ||
document.querySelector("#file-manager ul") ||
(() => {
const li = document.querySelector("li[data-file]");
return li ? li.closest("ul") : null;
})()
);
if (!list) return;
// 取得所有頁面 li不包含我們建立的 folder-node
const allPageItems = [...list.querySelectorAll("li[data-file]:not(.folder-node-item)")];
if (allPageItems.length === 0) return;
// 移除舊的資料夾節點(避免重複)
list.querySelectorAll(".folder-node").forEach(n => n.remove());
// 分組:根頁面 vs 子資料夾頁面
const rootItems = [];
const folderMap = {}; // folderName -> [li]
allPageItems.forEach(li => {
const pageKey = li.dataset.page || "";
if (pageKey.includes("/")) {
const folderName = pageKey.split("/").slice(0, -1).join("/");
if (!folderMap[folderName]) folderMap[folderName] = [];
folderMap[folderName].push(li);
li.remove(); // 先從扁平清單移除,稍後放進資料夾節點
} else {
rootItems.push(li);
}
});
// 每個資料夾建一個折疊節點並加到清單尾端
for (const folderName in folderMap) {
const folderNode = createFolderNode(folderName, folderMap[folderName]);
list.appendChild(folderNode);
}
}
function createFolderNode(folderPath, childItems) {
const displayName = folderPath.split("/").pop()
.replace(/-/g, " ")
.replace(/\b\w/g, c => c.toUpperCase());
const li = document.createElement("li");
li.className = "folder-node";
li.dataset.folder = folderPath;
li.style.cssText = "list-style:none;";
li.innerHTML = `
<div class="folder-header" style="
display:flex; align-items:center; gap:0.45rem;
padding:0.35rem 0.6rem; cursor:pointer; border-radius:6px;
user-select:none; color:inherit;
transition:background 0.15s;">
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
style="transition:transform 0.2s; flex-shrink:0; opacity:0.6;">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="#6366f1" style="flex-shrink:0;">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<span style="font-weight:500;">${displayName}</span>
</div>
<ul class="folder-children" style="padding-left:1.1rem; margin:0; list-style:none;"></ul>`;
const childrenUl = li.querySelector(".folder-children");
childItems.forEach(child => {
child.classList.add("folder-node-item");
childrenUl.appendChild(child);
});
// 折疊切換
const header = li.querySelector(".folder-header");
const chevron = li.querySelector(".chevron");
let collapsed = false;
header.addEventListener("mouseenter", () => { header.style.background = "rgba(99,102,241,0.08)"; });
header.addEventListener("mouseleave", () => { header.style.background = ""; });
header.addEventListener("click", (e) => {
if (e.target.closest("li[data-file]")) return;
collapsed = !collapsed;
childrenUl.style.display = collapsed ? "none" : "";
chevron.style.transform = collapsed ? "rotate(-90deg)" : "";
});
return li;
}
// ── 動態中文化 VvvebJS 元件與區塊 ─────────────────────────────────
function patchI18n() {
if (typeof Vvveb === "undefined" || !Vvveb.ComponentsGroup || Object.keys(Vvveb.ComponentsGroup).length === 0) {
setTimeout(patchI18n, 500);
return;
}
const translateMap = {
// 分類 (Groups)
"Grid": "網格系統",
"Components": "基礎元件",
"Basic": "基本",
"Typography": "排版",
"Media": "媒體",
"Forms": "表單",
"Widgets": "小工具",
"Advanced": "進階",
"Content": "內容區塊",
"Layout": "佈局",
"Blocks": "區塊",
"Features": "功能特色",
"Headers": "頁首",
"Footers": "頁尾",
"Pricing": "價格表",
"Testimonials": "客戶評價",
"Cards": "卡片",
"Services": "服務",
"Team": "團隊",
"About": "關於我們",
// 元件 (Components)
"Grid Row": "網格列 (Row)",
"Grid Column": "網格欄 (Column)",
"Container": "容器 (Container)",
"Heading": "標題 (Heading)",
"Link": "超連結 (Link)",
"Image": "圖片 (Image)",
"Video": "影片 (Video)",
"Paragraph": "段落文字",
"List": "列表",
"List item": "列表項目",
"Button": "按鈕",
"Button Group": "按鈕群組",
"Button Toolbar": "按鈕工具列",
"Form": "表單",
"Text Input": "文字輸入框",
"Textarea": "多行文字框",
"Checkbox": "核取方塊",
"Radio": "單選按鈕",
"Select": "下拉選單",
"Label": "標籤文字",
"Alert": "警告提示",
"Badge": "徽章 (Badge)",
"Card": "卡片 (Card)",
"Table": "表格",
"Progress": "進度條",
"Navbar": "導覽列 (Navbar)",
"Breadcrumb": "麵包屑 (Breadcrumb)",
"Pagination": "分頁",
"Jumbotron": "大型看板",
"Panel": "面板",
"Icon": "圖示",
"Divider": "分隔線",
"Map": "地圖",
"Audio": "音訊",
"File Upload": "檔案上傳",
"Date Input": "日期輸入框",
"Password Input": "密碼輸入框",
"Email Input": "Email 輸入框",
// 屬性面板 - 標題與一般屬性
"General": "一般",
"Id": "ID",
"Title": "標題 (Title)",
"Class": "類別 (Class)",
"Display": "顯示 (Display)",
"Position": "定位 (Position)",
"Top": "上",
"Left": "左",
"Bottom": "下",
"Right": "右",
"Float": "浮動 (Float)",
"Opacity": "透明度 (Opacity)",
"Background": "背景 (Background)",
"Background Color": "背景顏色",
"Text Color": "文字顏色",
"Color": "顏色",
// 排版與字體
"Font size": "字體大小",
"Font weight": "字體粗細",
"Font family": "字體家族",
"Text align": "文字對齊",
"Line height": "行高",
"Letter spacing": "字距",
"Text decoration": "文字裝飾",
"Decoration Color": "裝飾顏色",
"Decoration style": "裝飾樣式",
// 尺寸與間距
"Size": "尺寸",
"Width": "寬度",
"Height": "高度",
"Min Width": "最小寬度",
"Min Height": "最小高度",
"Max Width": "最大寬度",
"Max Height": "最大高度",
"Margin": "外邊距 (Margin)",
"Padding": "內邊距 (Padding)",
// 邊框與陰影
"Border": "邊框 (Border)",
"Style": "樣式",
"Radius": "圓角 (Radius)",
"Radius Top Left": "左上圓角",
"Radius Top Right": "右上圓角",
"Radius Bottom Left": "左下圓角",
"Radius Bottom Right": "右下圓角",
"Background Image": "背景圖片",
"Box Shadow": "盒子陰影",
"Text Shadow": "文字陰影",
"Filter": "濾鏡",
"Transform": "變形",
"Transition": "過渡效果",
// 選項值
"Default": "預設",
"Primary": "主要 (Primary)",
"Secondary": "次要 (Secondary)",
"Success": "成功 (Success)",
"Danger": "危險 (Danger)",
"Warning": "警告 (Warning)",
"Info": "資訊 (Info)",
"Light": "亮色 (Light)",
"Dark": "暗色 (Dark)",
"White": "白色",
"Block": "區塊 (Block)",
"Inline": "行內 (Inline)",
"Inline Block": "行內區塊 (Inline Block)",
"Flex": "彈性 (Flex)",
"Inline Flex": "行內彈性 (Inline Flex)",
"Grid": "網格 (Grid)",
"Static": "靜態 (Static)",
"Fixed": "固定 (Fixed)",
"Relative": "相對 (Relative)",
"Absolute": "絕對 (Absolute)",
// 裝置響應式顯示
"Hide based on device screen width": "依裝置螢幕寬度隱藏",
"Extra small devices": "超小螢幕 (xs)",
"Small devices": "小螢幕 (sm)",
"Medium devices": "中螢幕 (md)",
"Large devices": "大螢幕 (lg)",
"Xl devices": "超大螢幕 (xl)",
"Xxl devices": "超超大螢幕 (xxl)",
// AOS 動畫
"Animate on scroll": "滾動動畫 (AOS)",
"Animation type": "動畫類型",
"Duration": "持續時間",
"Delay": "延遲",
"Play animation": "播放動畫",
"[none]": "[無]",
"Fade animations": "淡入淡出動畫",
"Fade": "淡入",
"Fade Up": "向上淡入",
"Fade down": "向下淡入",
"Fade left": "向左淡入",
"Fade right": "向右淡入",
"Fade up right": "向右上淡入",
"Fade up left": "向左上淡入",
"Fade down right": "向右下淡入",
"Fade down left": "向左下淡入",
"Flip animations": "翻轉動畫",
"Flip Up": "向上翻轉",
"Flip Down": "向下翻轉",
"Flip left": "向左翻轉",
"Flip right": "向右翻轉",
"Slide animations": "滑動動畫",
"Slide up": "向上滑動",
"Slide down": "向下滑動",
"Slide left": "向左滑動",
"Slide right": "向右滑動",
"Zoom animations": "縮放動畫",
"Zoom in": "放大",
"Zoom in up": "向上放大",
"Zoom in down": "向下放大",
"Zoom in left": "向左放大",
"Zoom in right": "向右放大",
"Zoom out": "縮小",
"Zoom out up": "向上縮小",
"Zoom out down": "向下縮小",
"Zoom out left": "向左縮小",
"Zoom out right": "向右縮小",
// 連結樣式提示
"Linked styles": "共用樣式提示"
};
const translateText = (text) => {
if (!text || typeof text !== "string") return text;
const trimmed = text.trim();
return translateMap[trimmed] ? translateMap[trimmed] : text;
};
// 1. 翻譯 Vvveb 物件內的資料 (供重新渲染時使用)
if (Vvveb.ComponentsGroup) {
const newGroups = {};
for (let group in Vvveb.ComponentsGroup) {
let tGroup = translateText(group);
newGroups[tGroup] = Vvveb.ComponentsGroup[group];
newGroups[tGroup].forEach(compName => {
let comp = Vvveb.Components.get(compName);
if (comp && comp.name) {
comp.name = translateText(comp.name);
}
});
}
Vvveb.ComponentsGroup = newGroups;
}
if (Vvveb.BlocksGroup) {
const newBlockGroups = {};
for (let group in Vvveb.BlocksGroup) {
let tGroup = translateText(group);
newBlockGroups[tGroup] = Vvveb.BlocksGroup[group];
}
Vvveb.BlocksGroup = newBlockGroups;
}
// 2. 翻譯所有元件的屬性面板 (Style, Advanced, etc)
if (Vvveb.Components && Vvveb.Components._components) {
Object.keys(Vvveb.Components._components).forEach(key => {
let comp = Vvveb.Components._components[key];
if (comp.properties) {
comp.properties.forEach(prop => {
if (prop.name) prop.name = translateText(prop.name);
if (prop.data) {
if (prop.data.header) prop.data.header = translateText(prop.data.header);
if (prop.data.text) prop.data.text = translateText(prop.data.text);
if (prop.data.options && Array.isArray(prop.data.options)) {
prop.data.options.forEach(opt => {
if (opt.text) opt.text = translateText(opt.text);
if (opt.title) opt.title = translateText(opt.title);
if (opt.optgroup) opt.optgroup = translateText(opt.optgroup);
});
}
}
});
}
});
}
// 3. 直接修改當前已渲染的 DOM 文字
document.querySelectorAll(".components-list .header span, .blocks-list .header span, .sections-list .header span").forEach(el => {
const txt = el.textContent.trim();
if (translateMap[txt]) {
el.textContent = translateMap[txt];
}
});
document.querySelectorAll(".components-list li .name, .blocks-list li .name, .sections-list li .name").forEach(el => {
const txt = el.textContent.trim();
if (translateMap[txt]) {
// 如果有子元素,只替換文本節點
if (el.childNodes.length > 0) {
for (let i = 0; i < el.childNodes.length; i++) {
if (el.childNodes[i].nodeType === 3 && el.childNodes[i].textContent.trim() === txt) {
el.childNodes[i].textContent = translateMap[txt];
break;
}
}
} else {
el.textContent = translateMap[txt];
}
}
});
console.log("[my-editor] VvvebJS components and blocks localized.");
}
// ── 啟用 Save 按鈕vvvebjs 預設 disabled──────────────────────
function enableSaveBtn() {
const btn = document.querySelector(".save-btn");
if (btn) {
btn.removeAttribute("disabled");
} else {
setTimeout(enableSaveBtn, 300);
}
}
// ── Ctrl+S 快捷鍵 ────────────────────────────────────────────────
document.addEventListener("keydown", function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (typeof Vvveb !== "undefined" && Vvveb.Builder && Vvveb.Builder.saveAjax) {
// 觸發原生儲存按鈕的 click
const btn = document.querySelector(".save-btn");
if (btn) {
btn.click();
} else {
Vvveb.Builder.saveAjax({});
}
}
}
});
// ── 初始化 ────────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
patchSaveAjax();
patchFileManager();
patchNewSection();
patchPageTree();
patchI18n();
enableSaveBtn();
});
} else {
patchSaveAjax();
patchFileManager();
patchNewSection();
patchPageTree();
patchI18n();
enableSaveBtn();
}
})();