794 lines
23 KiB
JavaScript
794 lines
23 KiB
JavaScript
(function () {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("edit") !== "1") return;
|
|
|
|
const state = {
|
|
content: null,
|
|
textTargets: new Map(),
|
|
imageTargets: new Map(),
|
|
sharedTextTargets: new Map(),
|
|
dirtyText: new Map(),
|
|
dirtyImages: new Map(),
|
|
undoStack: [],
|
|
autoSaveTimer: null,
|
|
saving: false,
|
|
toolbar: null,
|
|
statusEl: null,
|
|
authToken: null,
|
|
ownerEmail: "",
|
|
};
|
|
const apiBase = computeApiBasePath();
|
|
const TOKEN_STORAGE_KEY = "ikfreunde_editor_token";
|
|
|
|
const SECTION_ROOTS = {
|
|
hero: "main section.module-hero-teaser",
|
|
page_header: "main section.page-header",
|
|
projects: "main section.module-projects-teaser",
|
|
services: "main section:has(.services-teaser__content)",
|
|
team: "main section.text-image",
|
|
awards: "main section:has(.awards-teaser__content)",
|
|
contact: "main section.contact-teaser",
|
|
clients: "main section:has(.clients-teaser__content)",
|
|
};
|
|
|
|
const SHARED_ROOTS = {
|
|
navigation: "header.page-head",
|
|
cookie_layer: "#cookie-layer",
|
|
footer: "footer.site-footer",
|
|
};
|
|
|
|
init().catch((error) => {
|
|
console.error("[WYSIWYG] init failed", error);
|
|
alert("WYSIWYG init failed. Check console.");
|
|
});
|
|
|
|
async function init() {
|
|
injectCssClass();
|
|
createToolbar();
|
|
|
|
await handleResetTokenFlow();
|
|
const ready = await ensureEditorSession();
|
|
if (!ready) {
|
|
setStatus("Viewer mode (not authorized)");
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${apiBase}/api/content`, { cache: "no-store" });
|
|
if (!response.ok) throw new Error(`Failed to load ${apiBase}/api/content`);
|
|
state.content = await response.json();
|
|
|
|
mapSectionTextTargets();
|
|
mapSharedTextTargets();
|
|
mapSectionImageTargets();
|
|
mapSharedImageTargets();
|
|
|
|
setStatus("Edit mode ready");
|
|
}
|
|
|
|
function injectCssClass() {
|
|
document.documentElement.classList.add("ce-edit-mode");
|
|
}
|
|
|
|
function createToolbar() {
|
|
const toolbar = document.createElement("div");
|
|
toolbar.className = "ce-toolbar";
|
|
toolbar.innerHTML = [
|
|
'<strong>Editor Mode</strong>',
|
|
'<button type="button" data-ce-save>Save</button>',
|
|
'<button type="button" data-ce-undo>Undo</button>',
|
|
'<button type="button" data-ce-reset>Reset</button>',
|
|
'<button type="button" data-ce-logout>Logout</button>',
|
|
'<span class="ce-status" data-ce-status>Loading…</span>',
|
|
].join(" ");
|
|
|
|
document.body.appendChild(toolbar);
|
|
state.toolbar = toolbar;
|
|
state.statusEl = toolbar.querySelector("[data-ce-status]");
|
|
|
|
toolbar.querySelector("[data-ce-save]").addEventListener("click", () => saveNow());
|
|
toolbar.querySelector("[data-ce-undo]").addEventListener("click", () => undoLast());
|
|
toolbar.querySelector("[data-ce-reset]").addEventListener("click", () => triggerResetRequest());
|
|
toolbar.querySelector("[data-ce-logout]").addEventListener("click", () => logoutEditor());
|
|
}
|
|
|
|
function setStatus(message) {
|
|
if (state.statusEl) state.statusEl.textContent = message;
|
|
}
|
|
|
|
async function handleResetTokenFlow() {
|
|
const resetToken = params.get("reset_token");
|
|
if (!resetToken) return;
|
|
|
|
const newPassword = window.prompt("Reset token erkannt. Neues Passwort (mind. 8 Zeichen):");
|
|
if (!newPassword) return;
|
|
if (newPassword.length < 8) {
|
|
alert("Passwort zu kurz.");
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${apiBase}/api/editor/reset/confirm`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ token: resetToken, newPassword }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
alert(`Reset fehlgeschlagen: ${text}`);
|
|
return;
|
|
}
|
|
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("reset_token");
|
|
history.replaceState({}, "", url.toString());
|
|
alert("Passwort wurde zurückgesetzt. Bitte neu einloggen.");
|
|
}
|
|
|
|
async function ensureEditorSession() {
|
|
state.authToken = getStoredToken();
|
|
|
|
let status = await fetchEditorStatus();
|
|
if (!status.claimed) {
|
|
const claimed = await runClaimOnboarding();
|
|
if (!claimed) return false;
|
|
status = await fetchEditorStatus();
|
|
}
|
|
|
|
if (!status.authenticated) {
|
|
const loggedIn = await runLoginPrompt(status.owner_email || "");
|
|
if (!loggedIn) return false;
|
|
status = await fetchEditorStatus();
|
|
if (!status.authenticated) return false;
|
|
}
|
|
|
|
state.ownerEmail = status.owner_email || "";
|
|
return true;
|
|
}
|
|
|
|
async function fetchEditorStatus() {
|
|
const response = await fetch(`${apiBase}/api/editor/status`, {
|
|
headers: authHeaders(),
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to load editor status.");
|
|
return response.json();
|
|
}
|
|
|
|
async function runClaimOnboarding() {
|
|
const email = window.prompt("Ersteinrichtung: Editor E-Mail");
|
|
if (!email) return false;
|
|
const password = window.prompt("Ersteinrichtung: Editor Passwort (mind. 8 Zeichen)");
|
|
if (!password || password.length < 8) {
|
|
alert("Passwort zu kurz.");
|
|
return false;
|
|
}
|
|
|
|
const response = await fetch(`${apiBase}/api/editor/claim`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
alert(`Claim fehlgeschlagen: ${text}`);
|
|
return false;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.token) {
|
|
state.authToken = data.token;
|
|
setStoredToken(data.token);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function runLoginPrompt(defaultEmail) {
|
|
const email = window.prompt("Editor Login E-Mail", defaultEmail || "");
|
|
if (!email) return false;
|
|
const password = window.prompt("Editor Passwort");
|
|
if (!password) return false;
|
|
|
|
const response = await fetch(`${apiBase}/api/editor/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const wantsReset = window.confirm(
|
|
"Login fehlgeschlagen. Passwort-Reset anfordern (Token wird in content/.editor-reset.json erzeugt)?"
|
|
);
|
|
if (wantsReset) {
|
|
await triggerResetRequest(email);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.token) {
|
|
state.authToken = data.token;
|
|
setStoredToken(data.token);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function triggerResetRequest(prefilledEmail) {
|
|
const email = prefilledEmail || window.prompt("Passwort-Reset: E-Mail");
|
|
if (!email) return;
|
|
|
|
const response = await fetch(`${apiBase}/api/editor/reset/request`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
alert("Reset konnte nicht angefordert werden.");
|
|
return;
|
|
}
|
|
|
|
alert(
|
|
"Reset angefordert. Prüfe im Container die Datei content/.editor-reset.json und nutze den reset_url Link."
|
|
);
|
|
}
|
|
|
|
async function logoutEditor() {
|
|
try {
|
|
await fetch(`${apiBase}/api/editor/logout`, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
});
|
|
} catch (_e) {
|
|
// ignore network failure here
|
|
}
|
|
clearStoredToken();
|
|
state.authToken = null;
|
|
alert("Abgemeldet. Seite neu laden für Viewer-Modus.");
|
|
}
|
|
|
|
function authHeaders() {
|
|
const headers = {};
|
|
if (state.authToken) {
|
|
headers.Authorization = `Bearer ${state.authToken}`;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function getStoredToken() {
|
|
try {
|
|
return window.sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setStoredToken(token) {
|
|
try {
|
|
window.sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
|
|
} catch (_e) {
|
|
// ignore storage failures
|
|
}
|
|
}
|
|
|
|
function clearStoredToken() {
|
|
try {
|
|
window.sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
} catch (_e) {
|
|
// ignore storage failures
|
|
}
|
|
}
|
|
|
|
function mapSectionTextTargets() {
|
|
const sections = state.content.sections || {};
|
|
const textImageSections = Array.from(document.querySelectorAll("main section.text-image"));
|
|
|
|
Object.keys(sections).forEach((sectionName) => {
|
|
const sectionData = sections[sectionName];
|
|
if (!sectionData || !sectionData.texts) return;
|
|
|
|
let root = null;
|
|
if (sectionName === "team") root = textImageSections[0] || null;
|
|
else if (sectionName === "partners") root = textImageSections[1] || null;
|
|
else root = queryFirstCompat(SECTION_ROOTS[sectionName]);
|
|
|
|
if (!root) return;
|
|
|
|
const nodes = collectVisibleTextNodes(root);
|
|
const counters = {};
|
|
|
|
nodes.forEach((node) => {
|
|
const parent = node.parentElement;
|
|
if (!parent) return;
|
|
|
|
const tag = parent.tagName.toLowerCase();
|
|
counters[tag] = (counters[tag] || 0) + 1;
|
|
const key = `${tag}_${String(counters[tag]).padStart(3, "0")}`;
|
|
|
|
if (!(key in sectionData.texts)) return;
|
|
|
|
const id = `section:${sectionName}:${key}`;
|
|
const initialValue = normalizeText(node.nodeValue || "");
|
|
const wrapped = wrapTextNode(node, id);
|
|
if (!wrapped) return;
|
|
|
|
const target = {
|
|
id,
|
|
scope: "section",
|
|
section: sectionName,
|
|
key,
|
|
el: wrapped,
|
|
savedValue: initialValue,
|
|
currentValue: initialValue,
|
|
};
|
|
|
|
state.textTargets.set(id, target);
|
|
wireTextTarget(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
function mapSharedTextTargets() {
|
|
const shared = state.content.shared || {};
|
|
|
|
Object.entries(SHARED_ROOTS).forEach(([area, selector]) => {
|
|
const root = queryFirstCompat(selector);
|
|
const areaData = shared[area];
|
|
if (!root || !areaData || !Array.isArray(areaData.text_keys)) return;
|
|
|
|
const nodes = collectVisibleTextNodes(root);
|
|
nodes.forEach((node, index) => {
|
|
const key = areaData.text_keys[index];
|
|
if (!key) return;
|
|
|
|
const initial = normalizeText(node.nodeValue || "");
|
|
const id = `shared:${key}`;
|
|
const wrapped = wrapTextNode(node, id);
|
|
if (!wrapped) return;
|
|
|
|
const target = {
|
|
id,
|
|
scope: "shared",
|
|
area,
|
|
key,
|
|
el: wrapped,
|
|
savedValue: initial,
|
|
currentValue: initial,
|
|
};
|
|
|
|
if (!state.sharedTextTargets.has(key)) state.sharedTextTargets.set(key, []);
|
|
state.sharedTextTargets.get(key).push(target);
|
|
|
|
state.textTargets.set(`${id}:${area}:${index}`, target);
|
|
wireTextTarget(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
function mapSectionImageTargets() {
|
|
const sections = state.content.sections || {};
|
|
const textImageSections = Array.from(document.querySelectorAll("main section.text-image"));
|
|
|
|
Object.keys(sections).forEach((sectionName) => {
|
|
const sectionData = sections[sectionName];
|
|
if (!sectionData || !sectionData.images) return;
|
|
|
|
let root = null;
|
|
if (sectionName === "team") root = textImageSections[0] || null;
|
|
else if (sectionName === "partners") root = textImageSections[1] || null;
|
|
else root = queryFirstCompat(SECTION_ROOTS[sectionName]);
|
|
if (!root) return;
|
|
|
|
const images = Array.from(root.querySelectorAll("img[src]"));
|
|
images.forEach((img, i) => {
|
|
const key = `img_${String(i + 1).padStart(3, "0")}`;
|
|
if (!(key in sectionData.images)) return;
|
|
|
|
const id = `section:${sectionName}:${key}`;
|
|
const target = {
|
|
id,
|
|
scope: "section",
|
|
section: sectionName,
|
|
key,
|
|
el: img,
|
|
savedValue: {
|
|
src: img.getAttribute("src") || "",
|
|
alt: img.getAttribute("alt") || "",
|
|
},
|
|
currentValue: {
|
|
src: img.getAttribute("src") || "",
|
|
alt: img.getAttribute("alt") || "",
|
|
},
|
|
};
|
|
|
|
state.imageTargets.set(id, target);
|
|
wireImageTarget(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
function mapSharedImageTargets() {
|
|
const shared = state.content.shared || {};
|
|
|
|
Object.entries(SHARED_ROOTS).forEach(([area, selector]) => {
|
|
const root = queryFirstCompat(selector);
|
|
const areaData = shared[area];
|
|
if (!root || !areaData || !areaData.images) return;
|
|
|
|
const images = Array.from(root.querySelectorAll("img[src]"));
|
|
images.forEach((img, i) => {
|
|
const key = `img_${String(i + 1).padStart(3, "0")}`;
|
|
if (!(key in areaData.images)) return;
|
|
|
|
const id = `shared:${area}:${key}`;
|
|
const target = {
|
|
id,
|
|
scope: "shared",
|
|
area,
|
|
key,
|
|
el: img,
|
|
savedValue: {
|
|
src: img.getAttribute("src") || "",
|
|
alt: img.getAttribute("alt") || "",
|
|
},
|
|
currentValue: {
|
|
src: img.getAttribute("src") || "",
|
|
alt: img.getAttribute("alt") || "",
|
|
},
|
|
};
|
|
|
|
state.imageTargets.set(id, target);
|
|
wireImageTarget(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
function wireTextTarget(target) {
|
|
target.el.classList.add("ce-text-target");
|
|
|
|
target.el.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
|
|
target.el.addEventListener("dblclick", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
startInlineEdit(target);
|
|
});
|
|
}
|
|
|
|
function startInlineEdit(target) {
|
|
const el = target.el;
|
|
if (el.isContentEditable) return;
|
|
|
|
el.setAttribute("contenteditable", "true");
|
|
el.classList.add("ce-text-editing");
|
|
placeCaretAtEnd(el);
|
|
|
|
const onKeyDown = (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
el.blur();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
el.textContent = target.currentValue;
|
|
el.blur();
|
|
}
|
|
};
|
|
|
|
const onBlur = () => {
|
|
el.removeAttribute("contenteditable");
|
|
el.classList.remove("ce-text-editing");
|
|
el.removeEventListener("keydown", onKeyDown);
|
|
el.removeEventListener("blur", onBlur);
|
|
|
|
const nextValue = normalizeText(el.textContent || "");
|
|
applyTextChange(target, nextValue, true);
|
|
queueAutoSave();
|
|
};
|
|
|
|
el.addEventListener("keydown", onKeyDown);
|
|
el.addEventListener("blur", onBlur);
|
|
}
|
|
|
|
function applyTextChange(target, nextValue, pushUndo) {
|
|
const prevValue = target.currentValue;
|
|
if (nextValue === prevValue) return;
|
|
|
|
if (pushUndo) {
|
|
state.undoStack.push({ type: "text", id: target.id, prevValue, nextValue });
|
|
}
|
|
|
|
if (target.scope === "shared") {
|
|
const group = state.sharedTextTargets.get(target.key) || [];
|
|
group.forEach((t) => {
|
|
t.currentValue = nextValue;
|
|
t.el.textContent = nextValue;
|
|
});
|
|
state.dirtyText.set(`shared:${target.key}`, nextValue);
|
|
} else {
|
|
target.currentValue = nextValue;
|
|
target.el.textContent = nextValue;
|
|
state.dirtyText.set(target.id, nextValue);
|
|
}
|
|
|
|
markDirtyStatus();
|
|
}
|
|
|
|
function wireImageTarget(target) {
|
|
target.el.classList.add("ce-image-target");
|
|
target.el.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openImageOverlay(target);
|
|
});
|
|
}
|
|
|
|
function openImageOverlay(target) {
|
|
closeImageOverlay();
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "ce-image-overlay";
|
|
overlay.innerHTML = [
|
|
'<div class="ce-image-overlay-card">',
|
|
"<h4>Image Content</h4>",
|
|
'<label>src <input type="text" data-ce-src /></label>',
|
|
'<label>alt <input type="text" data-ce-alt /></label>',
|
|
'<div class="ce-image-overlay-actions">',
|
|
'<button type="button" data-ce-cancel>Cancel</button>',
|
|
'<button type="button" data-ce-apply>Apply</button>',
|
|
"</div>",
|
|
"</div>",
|
|
].join("");
|
|
|
|
const srcInput = overlay.querySelector("[data-ce-src]");
|
|
const altInput = overlay.querySelector("[data-ce-alt]");
|
|
srcInput.value = target.currentValue.src;
|
|
altInput.value = target.currentValue.alt;
|
|
|
|
overlay.querySelector("[data-ce-cancel]").addEventListener("click", () => closeImageOverlay());
|
|
overlay.querySelector("[data-ce-apply]").addEventListener("click", async () => {
|
|
const nextSrc = srcInput.value.trim();
|
|
const nextAlt = normalizeText(altInput.value || "");
|
|
|
|
const allow = await checkImageRatioWarning(target.currentValue.src, nextSrc);
|
|
if (!allow) return;
|
|
|
|
applyImageChange(target, { src: nextSrc || target.currentValue.src, alt: nextAlt }, true);
|
|
closeImageOverlay();
|
|
queueAutoSave();
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
function closeImageOverlay() {
|
|
const existing = document.querySelector(".ce-image-overlay");
|
|
if (existing) existing.remove();
|
|
}
|
|
|
|
async function checkImageRatioWarning(currentSrc, nextSrc) {
|
|
if (!nextSrc || nextSrc === currentSrc) return true;
|
|
|
|
try {
|
|
const currentRatio = await loadImageRatio(currentSrc);
|
|
const nextRatio = await loadImageRatio(nextSrc);
|
|
|
|
if (!currentRatio || !nextRatio) return true;
|
|
|
|
const diff = Math.abs(nextRatio - currentRatio) / currentRatio;
|
|
if (diff > 0.15) {
|
|
return window.confirm(
|
|
"Warning: the new image ratio differs by more than 15%. Save anyway?"
|
|
);
|
|
}
|
|
return true;
|
|
} catch (_e) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function loadImageRatio(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
if (!img.naturalWidth || !img.naturalHeight) return resolve(null);
|
|
resolve(img.naturalWidth / img.naturalHeight);
|
|
};
|
|
img.onerror = () => reject(new Error("image load failed"));
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
function applyImageChange(target, nextValue, pushUndo) {
|
|
const prevValue = { ...target.currentValue };
|
|
if (prevValue.src === nextValue.src && prevValue.alt === nextValue.alt) return;
|
|
|
|
if (pushUndo) {
|
|
state.undoStack.push({
|
|
type: "image",
|
|
id: target.id,
|
|
prevValue,
|
|
nextValue: { ...nextValue },
|
|
});
|
|
}
|
|
|
|
target.currentValue = { ...nextValue };
|
|
target.el.setAttribute("src", nextValue.src);
|
|
target.el.setAttribute("alt", nextValue.alt);
|
|
state.dirtyImages.set(target.id, { ...nextValue });
|
|
|
|
markDirtyStatus();
|
|
}
|
|
|
|
function undoLast() {
|
|
const action = state.undoStack.pop();
|
|
if (!action) return;
|
|
|
|
if (action.type === "text") {
|
|
const target = findTextTargetById(action.id);
|
|
if (!target) return;
|
|
applyTextChange(target, action.prevValue, false);
|
|
return;
|
|
}
|
|
|
|
if (action.type === "image") {
|
|
const target = state.imageTargets.get(action.id);
|
|
if (!target) return;
|
|
applyImageChange(target, action.prevValue, false);
|
|
}
|
|
}
|
|
|
|
function findTextTargetById(id) {
|
|
for (const target of state.textTargets.values()) {
|
|
if (target.id === id) return target;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function queueAutoSave() {
|
|
clearTimeout(state.autoSaveTimer);
|
|
state.autoSaveTimer = setTimeout(() => saveNow(), 500);
|
|
}
|
|
|
|
async function saveNow() {
|
|
if (state.saving) return;
|
|
if (state.dirtyText.size === 0 && state.dirtyImages.size === 0) {
|
|
setStatus("No changes");
|
|
return;
|
|
}
|
|
|
|
state.saving = true;
|
|
setStatus("Saving…");
|
|
|
|
const textChanges = [];
|
|
const imageChanges = [];
|
|
|
|
for (const [id, value] of state.dirtyText.entries()) {
|
|
if (id.startsWith("shared:")) {
|
|
textChanges.push({ scope: "shared", key: id.replace("shared:", ""), value });
|
|
} else {
|
|
const [, section, key] = id.split(":");
|
|
textChanges.push({ scope: "section", section, key, value });
|
|
}
|
|
}
|
|
|
|
for (const [id, value] of state.dirtyImages.entries()) {
|
|
const parts = id.split(":");
|
|
if (parts[0] === "shared") {
|
|
imageChanges.push({ scope: "shared", area: parts[1], key: parts[2], ...value });
|
|
} else {
|
|
imageChanges.push({ scope: "section", section: parts[1], key: parts[2], ...value });
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/save`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
body: JSON.stringify({ textChanges, imageChanges }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Save failed");
|
|
}
|
|
|
|
markSaved();
|
|
setStatus("Saved");
|
|
} catch (error) {
|
|
console.error("[WYSIWYG] save error", error);
|
|
setStatus("Save failed");
|
|
alert("Save failed. Check console.");
|
|
} finally {
|
|
state.saving = false;
|
|
}
|
|
}
|
|
|
|
function markSaved() {
|
|
for (const target of state.textTargets.values()) {
|
|
target.savedValue = target.currentValue;
|
|
}
|
|
|
|
for (const target of state.imageTargets.values()) {
|
|
target.savedValue = { ...target.currentValue };
|
|
}
|
|
|
|
state.dirtyText.clear();
|
|
state.dirtyImages.clear();
|
|
}
|
|
|
|
function markDirtyStatus() {
|
|
const total = state.dirtyText.size + state.dirtyImages.size;
|
|
setStatus(total > 0 ? `${total} unsaved change(s)` : "No changes");
|
|
}
|
|
|
|
function collectVisibleTextNodes(root) {
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
if (!node || !node.parentElement) return NodeFilter.FILTER_REJECT;
|
|
const parentTag = node.parentElement.tagName.toLowerCase();
|
|
if (["script", "style", "noscript", "template", "svg", "path", "defs"].includes(parentTag)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (normalizeText(node.nodeValue || "") === "") return NodeFilter.FILTER_REJECT;
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
},
|
|
});
|
|
|
|
const nodes = [];
|
|
let current = walker.nextNode();
|
|
while (current) {
|
|
nodes.push(current);
|
|
current = walker.nextNode();
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function wrapTextNode(node, id) {
|
|
if (!node.parentNode) return null;
|
|
const span = document.createElement("span");
|
|
span.className = "ce-inline-text";
|
|
span.dataset.ceTextId = id;
|
|
span.textContent = node.nodeValue || "";
|
|
node.parentNode.replaceChild(span, node);
|
|
return span;
|
|
}
|
|
|
|
function placeCaretAtEnd(el) {
|
|
el.focus();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
range.collapse(false);
|
|
const sel = window.getSelection();
|
|
if (!sel) return;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value || "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function queryFirstCompat(selector) {
|
|
if (!selector) return null;
|
|
|
|
if (selector.includes(":has(")) {
|
|
const base = selector.slice(0, selector.indexOf(":has("));
|
|
const inner = selector.slice(selector.indexOf(":has(") + 5, -1);
|
|
const candidates = Array.from(document.querySelectorAll(base));
|
|
return candidates.find((el) => el.querySelector(inner)) || null;
|
|
}
|
|
|
|
return document.querySelector(selector);
|
|
}
|
|
|
|
function computeApiBasePath() {
|
|
const path = window.location.pathname || "/";
|
|
const parts = path.split("/").filter(Boolean);
|
|
if (parts.length === 0) return "";
|
|
return `/${parts[0]}`;
|
|
}
|
|
})();
|