2026-05-17 22:11:46 +08:00
|
|
|
|
/**
|
2026-05-17 22:44:11 +08:00
|
|
|
|
* my-editor.js — VvvebJS 儲存與新增頁面橋接腳本
|
2026-05-18 12:45:57 +08:00
|
|
|
|
* 覆蓋 Vvveb.Builder.saveAjax 與 Vvveb.FileManager 行為,無縫串接到 Flask 後端,並引入精美網頁型暗黑系模態框
|
2026-05-17 22:11:46 +08:00
|
|
|
|
*/
|
|
|
|
|
|
(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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 12:45:57 +08:00
|
|
|
|
// ── 頂級網頁型模態框 (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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 14:26:46 +08:00
|
|
|
|
// ── 三欄位頁面表單對話框 (頁面名稱 / 檔案名稱 / 資料夾) ────────────
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// ── 覆蓋 Vvveb.Builder.saveAjax ──────────────────────────────────
|
2026-05-17 22:11:46 +08:00
|
|
|
|
function patchSaveAjax() {
|
|
|
|
|
|
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
|
|
|
|
|
|
setTimeout(patchSaveAjax, 200);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* VvvebJS 原始簽名: saveAjax(data, saveUrl, callback, error)
|
|
|
|
|
|
*/
|
|
|
|
|
|
Vvveb.Builder.saveAjax = function (data, saveUrl, callback, error) {
|
2026-05-17 22:11:46 +08:00
|
|
|
|
if (!SLUG) {
|
|
|
|
|
|
showToast("錯誤:找不到專案識別碼 (slug)", "error");
|
2026-05-17 22:44:11 +08:00
|
|
|
|
if (error) error(new Error("Missing slug"));
|
2026-05-17 22:11:46 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// 確保 data 為物件並填入專案識別碼
|
|
|
|
|
|
if (typeof data !== "object" || data === null) {
|
|
|
|
|
|
data = {};
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
2026-05-17 22:44:11 +08:00
|
|
|
|
data.slug = SLUG;
|
2026-05-17 22:11:46 +08:00
|
|
|
|
|
2026-05-18 17:23:00 +08:00
|
|
|
|
// 保留完整相對路徑,不再扁平化處理檔名,以支援子資料夾
|
|
|
|
|
|
// if (data.file) {
|
|
|
|
|
|
// const parts = data.file.split("/");
|
|
|
|
|
|
// data.file = parts[parts.length - 1];
|
|
|
|
|
|
// }
|
2026-05-17 22:11:46 +08:00
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// 如果不是新增頁面(即一般的儲存頁面),且未帶有 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);
|
|
|
|
|
|
}
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 12:45:57 +08:00
|
|
|
|
// 顯示儲存中狀態
|
2026-05-17 22:11:46 +08:00
|
|
|
|
const saveBtn = document.querySelector(".save-btn");
|
2026-05-17 22:44:11 +08:00
|
|
|
|
if (saveBtn && !isNewPage) {
|
2026-05-17 22:11:46 +08:00
|
|
|
|
saveBtn.querySelector(".loading")?.classList.remove("d-none");
|
|
|
|
|
|
saveBtn.querySelector(".button-text")?.classList.add("d-none");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// 發送 POST 請求至 Flask API
|
2026-05-17 22:11:46 +08:00
|
|
|
|
fetch("/api/save", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
2026-05-17 22:44:11 +08:00
|
|
|
|
body: new URLSearchParams(data).toString(),
|
2026-05-17 22:11:46 +08:00
|
|
|
|
})
|
2026-05-17 22:44:11 +08:00
|
|
|
|
.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
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-17 22:11:46 +08:00
|
|
|
|
} else {
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// 一般儲存成功
|
|
|
|
|
|
showToast("✓ 已儲存:" + (resData.saved || data.file));
|
|
|
|
|
|
if (callback) callback(resData);
|
|
|
|
|
|
// 停用儲存按鈕(直至下一次變更)
|
|
|
|
|
|
document.querySelectorAll("#top-panel .save-btn").forEach((e) =>
|
|
|
|
|
|
e.setAttribute("disabled", "true")
|
|
|
|
|
|
);
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
2026-05-17 22:44:11 +08:00
|
|
|
|
showToast("儲存失敗:" + err.message, "error");
|
|
|
|
|
|
if (error) error(err);
|
2026-05-17 22:11:46 +08:00
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
2026-05-17 22:44:11 +08:00
|
|
|
|
if (saveBtn && !isNewPage) {
|
2026-05-17 22:11:46 +08:00
|
|
|
|
saveBtn.querySelector(".loading")?.classList.add("d-none");
|
|
|
|
|
|
saveBtn.querySelector(".button-text")?.classList.remove("d-none");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-17 22:44:11 +08:00
|
|
|
|
console.log("[my-editor] Robust Vvveb.Builder.saveAjax patched for slug:", SLUG);
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 12:45:57 +08:00
|
|
|
|
// ── 覆蓋 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-18 14:26:46 +08:00
|
|
|
|
// 覆蓋重新命名 / 複製頁面(三欄位對話框:頁面名稱、檔案名稱、儲存至資料夾)
|
2026-05-18 12:45:57 +08:00
|
|
|
|
Vvveb.FileManager.renamePage = function (element, e, duplicate = false) {
|
|
|
|
|
|
let page = element.dataset;
|
2026-05-18 14:26:46 +08:00
|
|
|
|
const currentTitle = element.querySelector("label > span")?.textContent?.trim() || page.file.replace(".html","");
|
|
|
|
|
|
const currentFile = page.file.replace(".html","");
|
|
|
|
|
|
const action = duplicate ? "複製頁面" : "重新命名頁面";
|
2026-05-18 12:45:57 +08:00
|
|
|
|
|
2026-05-18 14:26:46 +08:00
|
|
|
|
showModalPageForm(action, currentTitle, currentFile, "", function (formData) {
|
|
|
|
|
|
if (!formData) return;
|
|
|
|
|
|
let { title, filename, folder } = formData;
|
|
|
|
|
|
if (!filename) return;
|
|
|
|
|
|
if (!filename.endsWith(".html")) filename += ".html";
|
2026-05-18 12:45:57 +08:00
|
|
|
|
|
|
|
|
|
|
const bodyData = {
|
|
|
|
|
|
slug: SLUG,
|
|
|
|
|
|
file: page.file,
|
2026-05-18 14:26:46 +08:00
|
|
|
|
newfile: filename,
|
|
|
|
|
|
title: title,
|
|
|
|
|
|
folder: folder,
|
2026-05-18 12:45:57 +08:00
|
|
|
|
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}`);
|
2026-05-18 14:26:46 +08:00
|
|
|
|
const baseName = data.name || data.newfile.replace('.html', '');
|
|
|
|
|
|
const newTitle = data.title || title || baseName;
|
2026-05-18 12:45:57 +08:00
|
|
|
|
|
|
|
|
|
|
if (duplicate) {
|
|
|
|
|
|
let pageData = Object.assign({}, Vvveb.FileManager.pages[page.page]);
|
|
|
|
|
|
pageData["file"] = data.newfile;
|
2026-05-18 14:26:46 +08:00
|
|
|
|
pageData["title"] = newTitle;
|
2026-05-18 12:45:57 +08:00
|
|
|
|
pageData["url"] = data.url;
|
|
|
|
|
|
pageData["name"] = baseName;
|
|
|
|
|
|
Vvveb.FileManager.addPage(baseName, pageData);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const oldPageKey = page.page;
|
|
|
|
|
|
Vvveb.FileManager.pages[oldPageKey]["file"] = data.newfile;
|
2026-05-18 14:26:46 +08:00
|
|
|
|
Vvveb.FileManager.pages[oldPageKey]["title"] = newTitle;
|
2026-05-18 12:45:57 +08:00
|
|
|
|
Vvveb.FileManager.pages[oldPageKey]["url"] = data.url;
|
|
|
|
|
|
Vvveb.FileManager.pages[oldPageKey]["name"] = baseName;
|
|
|
|
|
|
|
|
|
|
|
|
let link = element.querySelector("a.view");
|
2026-05-18 14:26:46 +08:00
|
|
|
|
if (link) link.setAttribute("href", data.url);
|
|
|
|
|
|
|
|
|
|
|
|
let span = element.querySelector("label > span") || element.querySelector("span");
|
|
|
|
|
|
if (span) span.textContent = newTitle;
|
|
|
|
|
|
|
2026-05-18 12:45:57 +08:00
|
|
|
|
element.dataset.file = data.newfile;
|
|
|
|
|
|
element.dataset.page = baseName;
|
2026-05-18 14:26:46 +08:00
|
|
|
|
|
2026-05-18 12:45:57 +08:00
|
|
|
|
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.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 14:26:46 +08:00
|
|
|
|
// ── 頁面清單樹狀結構 ────────────────────────────────────────────────
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 13:40:19 +08:00
|
|
|
|
// ── 動態中文化 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": "密碼輸入框",
|
2026-05-18 13:55:15 +08:00
|
|
|
|
"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;
|
2026-05-18 13:40:19 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 翻譯 Vvveb 物件內的資料 (供重新渲染時使用)
|
|
|
|
|
|
if (Vvveb.ComponentsGroup) {
|
|
|
|
|
|
const newGroups = {};
|
|
|
|
|
|
for (let group in Vvveb.ComponentsGroup) {
|
2026-05-18 13:55:15 +08:00
|
|
|
|
let tGroup = translateText(group);
|
2026-05-18 13:40:19 +08:00
|
|
|
|
newGroups[tGroup] = Vvveb.ComponentsGroup[group];
|
|
|
|
|
|
newGroups[tGroup].forEach(compName => {
|
|
|
|
|
|
let comp = Vvveb.Components.get(compName);
|
|
|
|
|
|
if (comp && comp.name) {
|
2026-05-18 13:55:15 +08:00
|
|
|
|
comp.name = translateText(comp.name);
|
2026-05-18 13:40:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
Vvveb.ComponentsGroup = newGroups;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Vvveb.BlocksGroup) {
|
|
|
|
|
|
const newBlockGroups = {};
|
|
|
|
|
|
for (let group in Vvveb.BlocksGroup) {
|
2026-05-18 13:55:15 +08:00
|
|
|
|
let tGroup = translateText(group);
|
2026-05-18 13:40:19 +08:00
|
|
|
|
newBlockGroups[tGroup] = Vvveb.BlocksGroup[group];
|
|
|
|
|
|
}
|
|
|
|
|
|
Vvveb.BlocksGroup = newBlockGroups;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 13:55:15 +08:00
|
|
|
|
// 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 文字
|
2026-05-18 13:40:19 +08:00
|
|
|
|
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.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 22:11:46 +08:00
|
|
|
|
// ── 啟用 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) {
|
2026-05-17 22:44:11 +08:00
|
|
|
|
// 觸發原生儲存按鈕的 click
|
|
|
|
|
|
const btn = document.querySelector(".save-btn");
|
|
|
|
|
|
if (btn) {
|
|
|
|
|
|
btn.click();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Vvveb.Builder.saveAjax({});
|
|
|
|
|
|
}
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 初始化 ────────────────────────────────────────────────────────
|
|
|
|
|
|
if (document.readyState === "loading") {
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
|
|
|
|
patchSaveAjax();
|
2026-05-18 12:45:57 +08:00
|
|
|
|
patchFileManager();
|
|
|
|
|
|
patchNewSection();
|
2026-05-18 14:26:46 +08:00
|
|
|
|
patchPageTree();
|
2026-05-18 13:40:19 +08:00
|
|
|
|
patchI18n();
|
2026-05-17 22:11:46 +08:00
|
|
|
|
enableSaveBtn();
|
2026-05-18 17:48:30 +08:00
|
|
|
|
initCKEditorOverride();
|
2026-05-17 22:11:46 +08:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
patchSaveAjax();
|
2026-05-18 12:45:57 +08:00
|
|
|
|
patchFileManager();
|
|
|
|
|
|
patchNewSection();
|
2026-05-18 14:26:46 +08:00
|
|
|
|
patchPageTree();
|
2026-05-18 13:40:19 +08:00
|
|
|
|
patchI18n();
|
2026-05-17 22:11:46 +08:00
|
|
|
|
enableSaveBtn();
|
2026-05-18 17:48:30 +08:00
|
|
|
|
initCKEditorOverride();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 動態載入並覆寫 CKEditor ───────────────────────────────────────
|
|
|
|
|
|
function initCKEditorOverride() {
|
|
|
|
|
|
const script = document.createElement("script");
|
|
|
|
|
|
script.src = "https://cdn.ckeditor.com/4.22.1/full-all/ckeditor.js";
|
|
|
|
|
|
script.onload = () => {
|
|
|
|
|
|
// 隱藏版本過期警告
|
|
|
|
|
|
if (CKEDITOR.config) {
|
|
|
|
|
|
CKEDITOR.config.versionCheck = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ckeditorOptions = {
|
|
|
|
|
|
extraPlugins: "sharedspace",
|
|
|
|
|
|
removePlugins: "exportpdf", // 解決 exportpdf-no-token-url 錯誤
|
|
|
|
|
|
sharedSpaces: {
|
|
|
|
|
|
top: "#wysiwyg-editor",
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 覆寫 Vvveb 原生的 WysiwygEditor,改用 Vanilla JS
|
|
|
|
|
|
Vvveb.WysiwygEditor = {
|
|
|
|
|
|
isActive: false,
|
|
|
|
|
|
oldValue: '',
|
|
|
|
|
|
doc: false,
|
|
|
|
|
|
editor: false,
|
|
|
|
|
|
toolbar: false,
|
|
|
|
|
|
|
|
|
|
|
|
init: function(doc) {
|
|
|
|
|
|
this.doc = doc;
|
|
|
|
|
|
this.toolbar = document.getElementById('wysiwyg-editor');
|
|
|
|
|
|
if (this.toolbar) {
|
|
|
|
|
|
this.toolbar.classList.remove("default-editor");
|
|
|
|
|
|
this.toolbar.classList.add("ckeditor");
|
|
|
|
|
|
this.toolbar.innerHTML = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
edit: function(element) {
|
|
|
|
|
|
this.element = element;
|
|
|
|
|
|
this.isActive = true;
|
|
|
|
|
|
this.oldValue = element.innerHTML;
|
|
|
|
|
|
Vvveb.Builder.selectPadding = 10;
|
|
|
|
|
|
|
|
|
|
|
|
element.setAttribute('contenteditable', true);
|
|
|
|
|
|
element.setAttribute('spellcheckker', false);
|
|
|
|
|
|
|
|
|
|
|
|
CKEDITOR.disableAutoInline = true;
|
|
|
|
|
|
ckeditorOptions.sharedSpaces.top = this.toolbar;
|
|
|
|
|
|
|
|
|
|
|
|
if (this.editor) {
|
|
|
|
|
|
try { this.editor.destroy(); } catch(e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
this.editor = CKEDITOR.inline(element, ckeditorOptions);
|
|
|
|
|
|
|
|
|
|
|
|
if (this.toolbar) {
|
|
|
|
|
|
this.toolbar.style.display = "block";
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
destroy: function(element) {
|
|
|
|
|
|
this.isActive = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (this.editor) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.editor.destroy();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[my-editor] CKEditor destroy error", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.editor = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (element) {
|
|
|
|
|
|
element.removeAttribute('contenteditable');
|
|
|
|
|
|
element.removeAttribute('spellcheckker');
|
|
|
|
|
|
|
|
|
|
|
|
// 清除 CKEditor 殘留屬性,避免破壞 DOM 導致 VvvebJs 報錯
|
|
|
|
|
|
element.classList.remove('cke_editable', 'cke_editable_inline', 'cke_focus');
|
|
|
|
|
|
|
|
|
|
|
|
// 嘗試修復 Vvveb.Builder.selectedEl 為 null 的問題
|
|
|
|
|
|
if (typeof Vvveb.Builder !== "undefined" && !Vvveb.Builder.selectedEl) {
|
|
|
|
|
|
Vvveb.Builder.selectedEl = element;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.toolbar) {
|
|
|
|
|
|
this.toolbar.style.display = "none";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const node = this.element || element;
|
|
|
|
|
|
if (node && Vvveb.Undo) {
|
|
|
|
|
|
Vvveb.Undo.addMutation({
|
|
|
|
|
|
type: 'characterData',
|
|
|
|
|
|
target: node,
|
|
|
|
|
|
oldValue: this.oldValue,
|
|
|
|
|
|
newValue: node.innerHTML
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 嘗試立即初始化,以綁定 `#wysiwyg-editor` 元素
|
|
|
|
|
|
if (typeof Vvveb !== "undefined" && Vvveb.Builder && Vvveb.Builder.iframe) {
|
|
|
|
|
|
Vvveb.WysiwygEditor.init(Vvveb.Builder.iframe.contentDocument);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log("[my-editor] CKEditor 4 external override injected successfully.");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.head.appendChild(script);
|
2026-05-17 22:11:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
})();
|