Files
cms.techvanguard.vn/public/js/policies-block-editor.js
Tống Thành Đạt 2cee0172f2 refactor(cms): replace custom icon comboboxes with standardized icon picker
Replace the legacy `cms-icon-combobox` implementation with a new, standardized `icon-picker` across multiple admin modules. This change simplifies the UI by using a Bootstrap input group with a dedicated "Pick Icon" button and a preview cell.

Affected areas:
- Policies block editor
- Accreditation, Admissions, History, and Policies admin editor scripts
- Admissions calculator options view
This removes redundant icon list generation and manual dropdown management in favor of a centralized icon picker utility.
2026-04-22 20:27:42 +07:00

1354 lines
42 KiB
JavaScript

(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 toggleFullscreenButton = document.getElementById("toggleFullscreenButton");
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,
isFullscreen: false,
};
const stylePalette = [
{ value: "info", label: "Info" },
{ value: "warning", label: "Warning" },
{ value: "success", label: "Success" },
];
const iconOptions = Array.isArray(config.iconOptions) ? config.iconOptions : [];
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");
});
if (toggleFullscreenButton) {
toggleFullscreenButton.addEventListener("click", function () {
setEditorFullscreen(!ui.isFullscreen);
});
}
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);
root.addEventListener(
"wheel",
function (event) {
if (!ui.isFullscreen) {
return;
}
const interactiveTarget = event.target.closest(
".icon-combobox-panel, .icon-combobox-options",
);
if (interactiveTarget) {
return;
}
root.scrollTop += event.deltaY;
event.preventDefault();
},
{ passive: false },
);
document.addEventListener("click", handleGlobalClick);
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
if (ui.isFullscreen) {
setEditorFullscreen(false);
}
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();
updateFullscreenToggle();
renderBlocks();
renderPreview();
renderValidation(validateState());
}
function setEditorFullscreen(nextValue) {
ui.isFullscreen = Boolean(nextValue);
root.classList.toggle("is-editor-fullscreen", ui.isFullscreen);
document.body.classList.toggle("policy-editor-fullscreen", ui.isFullscreen);
updateFullscreenToggle();
}
function updateFullscreenToggle() {
if (!toggleFullscreenButton) {
return;
}
const label = toggleFullscreenButton.querySelector('[data-role="fullscreen-label"]');
const icon = toggleFullscreenButton.querySelector('[data-role="fullscreen-icon"]');
toggleFullscreenButton.setAttribute("aria-pressed", ui.isFullscreen ? "true" : "false");
toggleFullscreenButton.classList.toggle("btn-outline-secondary", !ui.isFullscreen);
toggleFullscreenButton.classList.toggle("btn-outline-primary", ui.isFullscreen);
if (label) {
label.textContent = ui.isFullscreen ? "Exit full screen" : "Full screen edit";
}
if (icon) {
icon.className = `fas ${ui.isFullscreen ? "fa-compress" : "fa-expand"} me-2`;
}
}
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 = '<i class="fas fa-cubes mb-3 d-block" style="font-size: 1.5rem"></i>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 = `
<div class="block-header">
<div class="block-title">
<div class="block-index">${index + 1}</div>
<div>
<div class="fw-semibold text-capitalize">${block.type}</div>
<div class="small text-muted">${describeBlock(block)}</div>
</div>
</div>
<div class="block-actions">
<button type="button" class="icon-button drag-handle" title="Drag block"><i class="fas fa-grip-vertical"></i></button>
<button type="button" class="icon-button" data-action="target-preview" title="Target in preview"><i class="fas fa-bullseye"></i></button>
<button type="button" class="icon-button" data-action="duplicate" title="Duplicate"><i class="fas fa-copy"></i></button>
<button type="button" class="icon-button" data-action="collapse" title="Collapse"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="icon-button is-danger" data-action="delete" title="Delete"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="block-body"></div>
`;
const body = card.querySelector(".block-body");
renderBlockBody(block, body, index);
card.addEventListener("click", function () {
setBlockSelection(block.id);
});
card.querySelector('[data-action="duplicate"]').addEventListener("click", function (event) {
event.stopPropagation();
duplicateBlock(block.id);
});
card.querySelector('[data-action="target-preview"]').addEventListener("click", function (event) {
event.stopPropagation();
focusPreviewBlock(block.id);
});
card.querySelector('[data-action="delete"]').addEventListener("click", function (event) {
event.stopPropagation();
deleteBlock(block.id);
});
card.querySelector('[data-action="collapse"]').addEventListener("click", function (event) {
event.stopPropagation();
card.classList.toggle("is-collapsed");
});
blockList.appendChild(card);
});
initializeSortable();
renderBlockSelection();
}
function initializeSortable() {
if (ui.sortable) {
ui.sortable.destroy();
ui.sortable = null;
}
if (!window.Sortable || blockList.children.length < 2) {
return;
}
ui.sortable = window.Sortable.create(blockList, {
animation: 180,
handle: ".drag-handle",
ghostClass: "sortable-ghost",
chosenClass: "sortable-chosen",
onStart: function () {
dropIndicator.classList.remove("is-visible");
},
onEnd: function (event) {
if (
typeof event.oldIndex !== "number" ||
typeof event.newIndex !== "number" ||
event.oldIndex === event.newIndex
) {
return;
}
moveBlock(event.oldIndex, event.newIndex);
},
});
}
function renderBlockBody(block, body, index) {
if (block.type === "heading") {
body.innerHTML = `
<div class="block-meta-grid">
<div>
<label class="form-label fw-semibold">Level</label>
<select class="form-select" data-field="level">
<option value="1"${Number(block.level) === 1 ? " selected" : ""}>H1</option>
<option value="2"${Number(block.level) === 2 ? " selected" : ""}>H2</option>
<option value="3"${Number(block.level) === 3 ? " selected" : ""}>H3</option>
</select>
</div>
</div>
`;
const editor = createEditable(block.html, "Heading text");
editor.dataset.blockId = block.id;
editor.dataset.field = "html";
body.appendChild(editor);
bindEditable(editor, function (value) {
block.html = value;
});
body.querySelector('[data-field="level"]').addEventListener("change", function () {
block.level = Number(this.value);
render();
});
return;
}
if (block.type === "paragraph") {
const editor = createEditable(block.html, "Write a paragraph. Type / for commands.");
editor.dataset.blockId = block.id;
editor.dataset.field = "html";
body.appendChild(editor);
bindEditable(editor, function (value) {
block.html = value;
}, { slashEnabled: true, blockIndex: index });
return;
}
if (block.type === "quote") {
body.innerHTML = `
<div class="block-meta-grid">
<div class="col-span-3">
<label class="form-label fw-semibold">Attribution</label>
<input type="text" class="form-control" data-field="caption" value="${escapeAttribute(block.caption || "")}" placeholder="Optional source" />
</div>
</div>
`;
const editor = createEditable(block.html, "Quote content");
editor.dataset.blockId = block.id;
editor.dataset.field = "html";
body.appendChild(editor);
bindEditable(editor, function (value) {
block.html = value;
});
body.querySelector('[data-field="caption"]').addEventListener("input", function () {
block.caption = this.value;
renderPreview();
renderValidation(validateState());
});
return;
}
if (block.type === "divider") {
body.innerHTML = `
<div class="text-muted small">
Divider blocks render as a semantic separator in the preview. They are useful between long content groups.
</div>
`;
return;
}
if (block.type === "callout") {
body.innerHTML = `
<div class="block-meta-grid">
<div>
<label class="form-label fw-semibold">Tone</label>
<select class="form-select" data-field="tone">
${stylePalette
.map(
(item) =>
`<option value="${item.value}"${block.tone === item.value ? " selected" : ""}>${item.label}</option>`,
)
.join("")}
</select>
</div>
<div>
<label class="form-label fw-semibold">Icon</label>
<div class="input-group" data-role="icon-picker-group">
<span class="input-group-text icon-preview-cell">
${block.icon ? `<i class="${escapeAttribute(resolveCalloutIconPreviewClass(block.icon))}"></i>` : ""}
</span>
<input
type="text"
class="form-control"
data-field="icon"
value="${escapeAttribute(block.icon || "")}"
placeholder="Click to pick..."
readonly
style="cursor:pointer;background:#fff"
data-icon-picker-value-mode="icon-name"
data-icon-picker-preview-prefix="${escapeAttribute(resolveCalloutIconPreviewPrefix(block.icon))}"
/>
<button type="button" class="btn btn-outline-secondary" data-action="pick-icon">
<i class="fas fa-icons me-1"></i>Pick Icon
</button>
</div>
</div>
<div>
<label class="form-label fw-semibold">Title</label>
<input type="text" class="form-control" data-field="title" value="${escapeAttribute(block.title || "")}" placeholder="Callout title" />
</div>
</div>
`;
const editor = createEditable(block.html, "Callout body");
editor.dataset.blockId = block.id;
editor.dataset.field = "html";
body.appendChild(editor);
bindEditable(editor, function (value) {
block.html = value;
});
initializeIconPicker(body.querySelector('[data-role="icon-picker-group"]'), block);
body.querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((input) => {
const syncField = function () {
block[this.dataset.field] = this.value;
renderPreview();
renderValidation(validateState());
};
input.addEventListener("input", syncField);
input.addEventListener("change", syncField);
});
return;
}
body.innerHTML = `
<div class="block-meta-grid">
<div>
<label class="form-label fw-semibold">List style</label>
<select class="form-select" data-field="style">
<option value="unordered"${block.style !== "ordered" ? " selected" : ""}>Bullet list</option>
<option value="ordered"${block.style === "ordered" ? " selected" : ""}>Numbered list</option>
</select>
</div>
</div>
<div class="list-items"></div>
<button type="button" class="btn btn-outline-primary mt-3" data-action="add-list-item">
<i class="fas fa-plus me-2"></i>Add item
</button>
`;
body.querySelector('[data-field="style"]').addEventListener("change", function () {
block.style = this.value;
renderPreview();
renderValidation(validateState());
});
const listItems = body.querySelector(".list-items");
(block.items || []).forEach((item, itemIndex) => {
const row = document.createElement("div");
row.className = "list-item";
row.innerHTML = `
<div class="text-muted fw-semibold mt-2">${itemIndex + 1}</div>
<div class="list-item-content" contenteditable="true" spellcheck="true" data-placeholder="List item content"></div>
<button type="button" class="icon-button is-danger" title="Delete item"><i class="fas fa-trash"></i></button>
`;
const content = row.querySelector(".list-item-content");
content.innerHTML = item.html || "";
content.dataset.blockId = block.id;
content.dataset.itemId = item.id;
bindEditable(content, function (value) {
item.html = value;
}, { slashEnabled: true, blockIndex: index });
row.querySelector("button").addEventListener("click", function () {
block.items.splice(itemIndex, 1);
render();
});
listItems.appendChild(row);
});
body.querySelector('[data-action="add-list-item"]').addEventListener("click", function () {
block.items.push({
id: `${block.id}-item-${Date.now()}`,
html: "",
});
render();
});
}
function createEditable(html, placeholder) {
const editor = document.createElement("div");
editor.className = "block-content";
editor.contentEditable = "true";
editor.spellcheck = true;
editor.dataset.placeholder = placeholder || "";
editor.innerHTML = html || "";
return editor;
}
function initializeIconPicker(wrapper, block) {
if (!wrapper) {
return;
}
const input = wrapper.querySelector('input[data-field="icon"]');
const button = wrapper.querySelector('[data-action="pick-icon"]');
if (!input || !button) {
return;
}
const syncIconValue = function (value) {
const nextValue = String(value || "").trim();
input.value = nextValue;
input.dataset.iconPickerPreviewPrefix = resolveCalloutIconPreviewPrefix(nextValue);
block.icon = nextValue;
syncCalloutIconPreview(input);
renderPreview();
renderValidation(validateState());
};
input.addEventListener("click", function () {
openSharedIconPicker(input, syncIconValue);
});
button.addEventListener("click", function () {
openSharedIconPicker(input, syncIconValue);
});
syncIconValue(block.icon || "");
}
function openSharedIconPicker(input, onPicked) {
if (!window.IconPicker || typeof window.IconPicker.open !== "function") {
return;
}
patchSharedIconPicker();
window.__policyBlockIconPickerInput = input;
window.__policyBlockIconPickerOnPicked = onPicked;
window.IconPicker.open(input);
}
function patchSharedIconPicker() {
if (!window.IconPicker || window.IconPicker.__policyBlockPatched) {
return;
}
const originalPick = typeof window.IconPicker.pick === "function"
? window.IconPicker.pick.bind(window.IconPicker)
: null;
if (!originalPick) {
return;
}
const patchedPick = function (value) {
originalPick(value);
const activeInput = window.__policyBlockIconPickerInput;
const onPicked = window.__policyBlockIconPickerOnPicked;
if (!activeInput || typeof onPicked !== "function") {
return;
}
activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(value)
|| resolveCalloutIconPreviewPrefix(activeInput.value || value);
onPicked(extractIconName(value));
activeInput.dispatchEvent(new Event("input", { bubbles: true }));
activeInput.dispatchEvent(new Event("change", { bubbles: true }));
window.__policyBlockIconPickerInput = null;
window.__policyBlockIconPickerOnPicked = null;
};
window.IconPicker.pick = patchedPick;
window.IconPickerPick = patchedPick;
window.IconPicker.__policyBlockPatched = true;
}
function syncCalloutIconPreview(input) {
if (!input) {
return;
}
const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell");
if (!previewCell) {
return;
}
const previewClass = resolveCalloutIconPreviewClass(input.value, input);
previewCell.innerHTML = previewClass ? `<i class="${escapeAttribute(previewClass)}"></i>` : "";
loadIconStyleLookup().then(function () {
const nextPrefix = resolveCalloutIconPreviewPrefix(input.value, input);
if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) {
input.dataset.iconPickerPreviewPrefix = nextPrefix;
const refreshedPreviewClass = resolveCalloutIconPreviewClass(input.value, input);
previewCell.innerHTML = refreshedPreviewClass
? `<i class="${escapeAttribute(refreshedPreviewClass)}"></i>`
: "";
}
});
}
function resolveCalloutIconPreviewClass(iconName, input) {
const normalizedValue = String(iconName || "").trim();
if (!normalizedValue) {
return "";
}
return `${resolveCalloutIconPreviewPrefix(normalizedValue, input)} ${normalizedValue}`.trim();
}
function resolveCalloutIconPreviewPrefix(iconName, input) {
const explicitPrefix = String(input?.dataset?.iconPickerPreviewPrefix || "").trim();
if (explicitPrefix && explicitPrefix !== "fa-solid") {
return explicitPrefix;
}
const normalizedIconName = extractIconName(iconName);
const knownStyles = window.__policyBlockIconStyleLookup?.[normalizedIconName] || [];
if (knownStyles.includes("fa-brands")) return "fa-brands";
if (knownStyles.includes("fa-regular")) return "fa-regular";
if (knownStyles.includes("fa-solid")) return "fa-solid";
return explicitPrefix || "fa-solid";
}
function extractIconName(value) {
return String(value || "")
.trim()
.split(/\s+/)
.find(
(token) =>
/^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token),
) || "";
}
function extractIconStyle(value) {
return String(value || "")
.trim()
.split(/\s+/)
.find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || "";
}
function loadIconStyleLookup() {
if (window.__policyBlockIconStyleLookupPromise) {
return window.__policyBlockIconStyleLookupPromise;
}
window.__policyBlockIconStyleLookupPromise = fetch("/js/fa-icons.json")
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then((json) => {
const lookup = {};
Object.entries(json || {}).forEach(([name, meta]) => {
lookup[`fa-${name}`] = (meta.styles || [])
.filter((style) => ["solid", "regular", "brands"].includes(style))
.map((style) => `fa-${style}`);
});
window.__policyBlockIconStyleLookup = lookup;
return lookup;
})
.catch(() => {
window.__policyBlockIconStyleLookup = window.__policyBlockIconStyleLookup || {};
return window.__policyBlockIconStyleLookup;
});
return window.__policyBlockIconStyleLookupPromise;
}
function bindEditable(element, onChange, options) {
element.addEventListener("input", function () {
normalizeEditorHtml(element);
onChange(element.innerHTML);
renderPreview();
renderValidation(validateState());
});
element.addEventListener("focus", function () {
ui.toolbarTarget = element;
setBlockSelection(element.dataset.blockId);
});
element.addEventListener("keydown", function (event) {
if (event.key === "Tab" && element.closest(".list-item-content")) {
event.preventDefault();
document.execCommand("insertHTML", false, "&nbsp;&nbsp;&nbsp;&nbsp;");
return;
}
if (options?.slashEnabled && event.key === "/") {
ui.slashContext = {
blockIndex: options.blockIndex,
blockId: element.dataset.blockId,
target: element,
};
const range = window.getSelection()?.getRangeAt(0);
if (range) {
window.setTimeout(function () {
showSlashMenu(range);
}, 0);
}
}
});
}
function normalizeEditorHtml(element) {
if (!element.innerHTML.trim()) {
element.innerHTML = "";
}
}
function handleSelectionChange() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
hideFloatingToolbar();
return;
}
const anchorNode = selection.anchorNode?.parentElement;
const editable = anchorNode?.closest(".block-content, .list-item-content");
if (!editable) {
hideFloatingToolbar();
return;
}
ui.toolbarTarget = editable;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) {
hideFloatingToolbar();
return;
}
floatingToolbar.style.top = `${Math.max(rect.top - 56, 12)}px`;
floatingToolbar.style.left = `${Math.max(rect.left + rect.width / 2 - 160, 12)}px`;
floatingToolbar.classList.add("is-visible");
}
function handleGlobalClick(event) {
if (!floatingToolbar.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) {
hideFloatingToolbar();
}
if (!linkPopover.contains(event.target) && !event.target.closest('[data-command="openLink"]')) {
hideLinkPopover();
}
if (!slashMenu.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) {
hideSlashMenu();
}
}
function hideFloatingToolbar() {
floatingToolbar.classList.remove("is-visible");
}
function showSlashMenu(range) {
const rect = range.getBoundingClientRect();
slashMenu.style.top = `${rect.bottom + 8}px`;
slashMenu.style.left = `${Math.max(rect.left, 12)}px`;
slashMenu.classList.add("is-visible");
}
function hideSlashMenu() {
slashMenu.classList.remove("is-visible");
ui.slashContext = null;
}
function insertSlashCommand(type) {
hideSlashMenu();
if (type === "link") {
openLinkPopover();
return;
}
if (!ui.slashContext) {
insertBlock(type);
return;
}
const blocks = getBlocks();
const index = blocks.findIndex((block) => block.id === ui.slashContext.blockId);
insertBlock(type, index + 1);
}
function applyCommand(command, value) {
if (!ui.toolbarTarget) {
return;
}
ui.toolbarTarget.focus();
if (command === "toggleCode") {
document.execCommand("insertHTML", false, `<code>${getSelectedText()}</code>`);
} 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,
`<a href="#${escapeAttribute(internalPolicyId)}" data-policy-id="${escapeAttribute(
internalPolicyId,
)}" data-link-kind="internal">${selectedText}</a>`,
);
} 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 = /<a\b([^>]*)>/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) => `<div>${escapeHtml(item)}</div>`).join("")
: "";
blockList.querySelectorAll(".block-card").forEach((card) => {
const issues = validation.blockIssues[card.dataset.blockId] || [];
card.classList.toggle("is-invalid", issues.length > 0);
});
}
function renderPreview() {
const blocks = getBlocks();
previewContent.innerHTML = "";
if (blocks.length === 0) {
previewContent.innerHTML = '<div class="text-muted small">No content yet.</div>';
return;
}
blocks.forEach((block) => {
const node = document.createElement("section");
node.className = "preview-block";
node.dataset.previewBlockId = block.id;
node.addEventListener("mouseenter", function () {
ui.hoveredPreviewBlockId = block.id;
renderBlockSelection();
});
node.addEventListener("mouseleave", function () {
ui.hoveredPreviewBlockId = null;
renderBlockSelection();
});
node.addEventListener("click", function () {
setBlockSelection(block.id);
document.querySelector(`[data-block-id="${block.id}"]`)?.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
if (block.type === "heading") {
const tagName = `h${block.level || 2}`;
const heading = document.createElement(tagName);
heading.className = block.level === 1 ? "display-6 fw-bold" : block.level === 2 ? "h3 fw-bold" : "h5 fw-semibold";
heading.innerHTML = block.html || "";
node.appendChild(heading);
} else if (block.type === "paragraph") {
const wrapper = document.createElement("div");
wrapper.innerHTML = block.html || "";
node.appendChild(wrapper);
} else if (block.type === "quote") {
const blockquote = document.createElement("blockquote");
blockquote.className = "border-start border-4 ps-3 my-2";
blockquote.innerHTML = block.html || "";
node.appendChild(blockquote);
if (block.caption) {
const cite = document.createElement("div");
cite.className = "small text-muted";
cite.textContent = block.caption;
node.appendChild(cite);
}
} else if (block.type === "divider") {
const divider = document.createElement("hr");
node.appendChild(divider);
} else if (block.type === "list") {
const list = document.createElement(block.style === "ordered" ? "ol" : "ul");
list.className = "ps-4";
(block.items || []).forEach((item) => {
const li = document.createElement("li");
li.innerHTML = item.html || "";
list.appendChild(li);
});
node.appendChild(list);
} else if (block.type === "callout") {
const callout = document.createElement("div");
callout.className = "callout-block";
callout.dataset.tone = block.tone || "info";
callout.innerHTML = `
${block.title ? `<div class="fw-semibold mb-2"><i class="${escapeAttribute(resolveCalloutIconPreviewClass(block.icon || "fa-circle-info"))} me-2"></i>${escapeHtml(block.title)}</div>` : ""}
<div>${block.html || ""}</div>
`;
node.appendChild(callout);
}
previewContent.appendChild(node);
});
renderPreviewSelection();
bindPreviewInternalLinks();
}
function bindPreviewInternalLinks() {
previewContent.querySelectorAll("a[data-policy-id]").forEach((link) => {
link.addEventListener("click", function (event) {
event.preventDefault();
showToast("Internal link", `Targets policy "${link.dataset.policyId}" on the public page.`, "info");
});
});
}
function renderBlockSelection() {
blockList.querySelectorAll(".block-card").forEach((card) => {
const isSelected = card.dataset.blockId === ui.selectedBlockId;
const isPreviewHovered = card.dataset.blockId === ui.hoveredPreviewBlockId;
card.classList.toggle("is-selected", isSelected || isPreviewHovered);
});
}
function renderPreviewSelection() {
previewContent.querySelectorAll(".preview-block").forEach((node) => {
const blockId = node.dataset.previewBlockId;
node.classList.toggle(
"is-highlighted",
blockId === ui.selectedBlockId || blockId === ui.hoveredPreviewBlockId,
);
node.classList.toggle("is-targeted", blockId === ui.targetedPreviewBlockId);
});
}
function focusPreviewBlock(blockId) {
setBlockSelection(blockId);
const node = previewContent.querySelector(`[data-preview-block-id="${blockId}"]`);
if (!node) {
return;
}
if (ui.previewTargetTimer) {
window.clearTimeout(ui.previewTargetTimer);
}
ui.targetedPreviewBlockId = blockId;
renderPreviewSelection();
node.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
ui.previewTargetTimer = window.setTimeout(function () {
ui.targetedPreviewBlockId = null;
renderPreviewSelection();
ui.previewTargetTimer = null;
}, 1600);
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
function stripHtml(value) {
return String(value || "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function showToast(title, message, type) {
if (window.bootstrap && typeof bootstrap.Toast === "function") {
const container =
document.querySelector(".toast-container") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
toast.setAttribute("role", "alert");
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(title)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(toast);
new bootstrap.Toast(toast, { autohide: true, delay: 2500 }).show();
toast.addEventListener("hidden.bs.toast", function () {
toast.remove();
});
return;
}
window.alert(`${title}: ${message}`);
}
function createToastContainer() {
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
document.body.appendChild(container);
return container;
}
function readJsonScript(id) {
const node = document.getElementById(id);
if (!node) {
return null;
}
try {
return JSON.parse(node.textContent || "null");
} catch (error) {
console.error(`Failed to parse JSON script "${id}"`, error);
return null;
}
}
})();