(function () { const root = document.getElementById("policyBlockEditor"); const form = document.getElementById("policyBlockEditorForm"); const pageJsonInput = document.getElementById("pageJson"); const intentInput = document.getElementById("editorIntent"); const blockList = document.getElementById("blockList"); const previewContent = document.getElementById("previewContent"); const blockTypeSelect = document.getElementById("blockTypeSelect"); const addBlockButton = document.getElementById("addBlockButton"); const validationBanner = document.getElementById("validationBanner"); const validationSummary = document.getElementById("validationSummary"); const previewDevice = document.getElementById("previewDevice"); const dropIndicator = document.getElementById("dropIndicator"); const floatingToolbar = document.getElementById("floatingToolbar"); const linkPopover = document.getElementById("linkPopover"); const slashMenu = document.getElementById("slashMenu"); const linkUrlInput = document.getElementById("linkUrlInput"); const linkPolicySelect = document.getElementById("linkPolicySelect"); const saveLinkButton = document.getElementById("saveLinkButton"); const removeLinkButton = document.getElementById("removeLinkButton"); const data = readJsonScript("policyBlockEditorDataPayload"); const config = readJsonScript("policyBlockEditorConfigData"); if (!root || !form || !pageJsonInput || !blockList || !previewContent || !data || !config) { return; } const defaultContent = { content: { blocks: [] }, }; const state = JSON.parse(JSON.stringify(data.content ? data : defaultContent)); state.policy = data.policy; state.content = state.content || defaultContent.content; const ui = { previewMode: "desktop", selectedBlockId: null, targetedPreviewBlockId: null, hoveredPreviewBlockId: null, pendingLinkRange: null, slashContext: null, toolbarTarget: null, sortable: null, previewTargetTimer: null, }; const stylePalette = [ { value: "info", label: "Info" }, { value: "warning", label: "Warning" }, { value: "success", label: "Success" }, ]; bindStaticEvents(); render(); window.policyBlockEditorDebug = { getState: function () { return JSON.parse(JSON.stringify(state)); }, moveBlockById: function (language, blockId, newIndex) { const blocks = getBlocks(); const oldIndex = blocks.findIndex((block) => block.id === blockId); if (oldIndex === -1) { return false; } moveBlock(oldIndex, newIndex); return true; }, }; function bindStaticEvents() { root.querySelectorAll("[data-preview-mode]").forEach((button) => { button.addEventListener("click", function () { ui.previewMode = this.dataset.previewMode; updatePreviewModeButtons(); }); }); addBlockButton.addEventListener("click", function () { insertBlock(blockTypeSelect.value || "paragraph"); }); form.querySelectorAll("[data-intent]").forEach((button) => { button.addEventListener("click", function () { intentInput.value = this.dataset.intent || "save"; }); }); form.addEventListener("submit", function (event) { const validation = validateState(); if (validation.errors.length > 0) { event.preventDefault(); renderValidation(validation); showToast("Validation", "Fix the highlighted content before saving.", "danger"); return; } pageJsonInput.value = JSON.stringify({ content: state.content, }); }); document.addEventListener("selectionchange", handleSelectionChange); document.addEventListener("click", handleGlobalClick); document.addEventListener("keydown", function (event) { if (event.key === "Escape") { hideFloatingToolbar(); hideLinkPopover(); hideSlashMenu(); } }); floatingToolbar.addEventListener("mousedown", function (event) { event.preventDefault(); }); floatingToolbar.addEventListener("click", function (event) { const target = event.target.closest("[data-command]"); if (!target) { return; } const command = target.dataset.command; if (command === "openLink") { openLinkPopover(); return; } if (target.matches('input[type="color"]')) { return; } applyCommand(command); }); floatingToolbar.querySelectorAll('input[type="color"]').forEach((input) => { input.addEventListener("input", function () { applyCommand(this.dataset.command, this.value); }); }); saveLinkButton.addEventListener("click", applyLinkFromPopover); removeLinkButton.addEventListener("click", removeCurrentLink); slashMenu.querySelectorAll("[data-slash-type]").forEach((button) => { button.addEventListener("click", function () { insertSlashCommand(this.dataset.slashType); }); }); } function getBlocks() { return state.content.blocks; } function setBlockSelection(blockId) { ui.selectedBlockId = blockId; renderBlockSelection(); renderPreviewSelection(); } function render() { hideFloatingToolbar(); hideLinkPopover(); hideSlashMenu(); updatePreviewModeButtons(); renderBlocks(); renderPreview(); renderValidation(validateState()); } function updatePreviewModeButtons() { root.querySelectorAll("[data-preview-mode]").forEach((button) => { button.classList.toggle("is-active", button.dataset.previewMode === ui.previewMode); }); previewDevice.dataset.mode = ui.previewMode; } function createBlock(type) { const id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (type === "heading") { return { id, type, level: 2, html: "", }; } if (type === "list") { return { id, type, style: "unordered", items: [ { id: `${id}-item-1`, html: "" }, { id: `${id}-item-2`, html: "" }, ], }; } if (type === "quote") { return { id, type, html: "", caption: "", }; } if (type === "divider") { return { id, type }; } if (type === "callout") { return { id, type, tone: "info", title: "", icon: "fa-circle-info", html: "", }; } return { id, type: "paragraph", html: "", }; } function insertBlock(type, index) { const blocks = getBlocks(); const nextBlock = createBlock(type); const insertIndex = typeof index === "number" ? index : blocks.length; blocks.splice(insertIndex, 0, nextBlock); setBlockSelection(nextBlock.id); render(); } function duplicateBlock(blockId) { const blocks = getBlocks(); const index = blocks.findIndex((block) => block.id === blockId); if (index === -1) { return; } const source = JSON.parse(JSON.stringify(blocks[index])); source.id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (source.items) { source.items = source.items.map((item, itemIndex) => ({ ...item, id: `${source.id}-item-${itemIndex + 1}`, })); } blocks.splice(index + 1, 0, source); setBlockSelection(source.id); render(); } function deleteBlock(blockId) { const blocks = getBlocks(); const index = blocks.findIndex((block) => block.id === blockId); if (index === -1) { return; } blocks.splice(index, 1); if (ui.selectedBlockId === blockId) { ui.selectedBlockId = null; } render(); } function moveBlock(oldIndex, newIndex) { const blocks = getBlocks(); if ( oldIndex === newIndex || oldIndex < 0 || newIndex < 0 || oldIndex >= blocks.length || newIndex >= blocks.length ) { return; } const [moved] = blocks.splice(oldIndex, 1); blocks.splice(newIndex, 0, moved); render(); } function renderBlocks() { const blocks = getBlocks(); blockList.innerHTML = ""; if (blocks.length === 0) { const empty = document.createElement("div"); empty.className = "text-muted border rounded-4 p-4 text-center"; empty.innerHTML = 'No blocks yet. Add your first block to start editing.'; blockList.appendChild(empty); return; } const validation = validateState(); blocks.forEach((block, index) => { const card = document.createElement("article"); card.className = "block-card"; card.dataset.blockId = block.id; card.classList.toggle( "is-invalid", validation.blockIssues[block.id]?.length > 0, ); card.classList.toggle("is-selected", ui.selectedBlockId === block.id); card.innerHTML = `
${getSelectedText()}`);
} else if (command === "toggleHighlight") {
document.execCommand("hiliteColor", false, "#fef08a");
} else {
document.execCommand(command, false, value);
}
ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
function getSelectedText() {
return window.getSelection ? window.getSelection().toString() : "";
}
function openLinkPopover() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
ui.pendingLinkRange = selection.getRangeAt(0).cloneRange();
const rect = ui.pendingLinkRange.getBoundingClientRect();
linkPopover.style.top = `${rect.bottom + 12}px`;
linkPopover.style.left = `${Math.max(rect.left - 60, 12)}px`;
linkPopover.classList.add("is-visible");
const anchor = selection.anchorNode?.parentElement?.closest("a");
linkUrlInput.value = anchor?.getAttribute("href") || "";
linkPolicySelect.value = anchor?.getAttribute("data-policy-id") || "";
}
function hideLinkPopover() {
linkPopover.classList.remove("is-visible");
}
function applyLinkFromPopover() {
if (!ui.pendingLinkRange) {
return;
}
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(ui.pendingLinkRange);
const internalPolicyId = linkPolicySelect.value;
const href = linkUrlInput.value.trim();
const selectedText = getSelectedText() || href || internalPolicyId || "link";
if (internalPolicyId) {
document.execCommand(
"insertHTML",
false,
`${selectedText}`,
);
} else if (href) {
document.execCommand("createLink", false, href);
}
if (ui.toolbarTarget) {
ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
hideLinkPopover();
}
function removeCurrentLink() {
if (!ui.toolbarTarget) {
return;
}
ui.toolbarTarget.focus();
document.execCommand("unlink", false);
ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true }));
hideLinkPopover();
}
function describeBlock(block) {
if (block.type === "heading") {
return `H${block.level || 2}`;
}
if (block.type === "list") {
return `${block.style === "ordered" ? "Numbered" : "Bullet"} list`;
}
if (block.type === "callout") {
return `${block.tone || "info"} callout`;
}
return "Rich text";
}
function validateState() {
const errors = [];
const warnings = [];
const blockIssues = {};
const validPolicyIds = [state.policy.id].concat((config.policyOptions || []).map((item) => item.value));
let h1Count = 0;
getBlocks().forEach((block) => {
const issues = [];
if (block.type === "heading" && Number(block.level) === 1) {
h1Count += 1;
}
if (block.type === "divider") {
blockIssues[block.id] = issues;
return;
}
if (block.type === "list") {
if (!block.items?.length) {
issues.push("List needs at least one item.");
}
block.items?.forEach((item, index) => {
if (!stripHtml(item.html)) {
issues.push(`List item ${index + 1} is empty.`);
}
issues.push(...collectLinkIssues(item.html, validPolicyIds));
});
} else if (block.type === "callout") {
if (!stripHtml(block.html) && !stripHtml(block.title)) {
issues.push("Callout is empty.");
}
issues.push(...collectLinkIssues(block.html, validPolicyIds));
} else if (!stripHtml(block.html)) {
issues.push("Block is empty.");
} else {
issues.push(...collectLinkIssues(block.html, validPolicyIds));
}
warnings.push(...issues);
blockIssues[block.id] = issues;
});
if (h1Count > 1) {
errors.push("Content has more than one H1.");
}
if (getBlocks().length === 0) {
warnings.push("Content is empty.");
}
return {
errors,
warnings,
blockIssues,
};
}
function collectLinkIssues(html, validPolicyIds) {
const issues = [];
const anchorRegex = /]*)>/gi;
let match = anchorRegex.exec(String(html || ""));
while (match) {
const attrs = {};
String(match[1]).replace(/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g, function (_, key, __, a, b) {
attrs[key] = a || b || "";
return _;
});
const href = String(attrs.href || "").trim();
const policyId = String(attrs["data-policy-id"] || "").trim();
const kind = String(attrs["data-link-kind"] || "");
if (kind === "internal") {
if (!policyId || !validPolicyIds.includes(policyId)) {
issues.push("Internal link target is invalid.");
}
} else if (!href || !/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)) {
issues.push("Link URL is invalid.");
}
match = anchorRegex.exec(String(html || ""));
}
return issues;
}
function renderValidation(validation) {
const hasMessages = validation.errors.length > 0 || validation.warnings.length > 0;
validationBanner.classList.toggle("is-visible", hasMessages);
validationSummary.innerHTML = hasMessages
? validation.errors.concat(validation.warnings).slice(0, 8).map((item) => `