forked from UKSOURCE/cms.lams
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.
1354 lines
42 KiB
JavaScript
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, " ");
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function escapeAttribute(value) {
|
|
return escapeHtml(value).replace(/`/g, "`");
|
|
}
|
|
|
|
function stripHtml(value) {
|
|
return String(value || "")
|
|
.replace(/<br\s*\/?>/gi, " ")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/ /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;
|
|
}
|
|
}
|
|
})();
|