import { pageReference, newPageReference, splitPageReference } from "../../reference.js"; import { pageRoute } from "../../routes.js"; import { ComboBox } from "../../../widgets/combobox.js"; import { TabSet } from "../../../widgets/tab-set.js"; /** * Edit the content and placement-specific appearance of a CMap concept. * * The dialog owns its fields, tabs, image reader and input validation. It * returns normalized values to the workspace, which performs the actual CMap * editor transaction and persistence. */ export class CmapConceptDialog { constructor(dialog, tr, appearanceEditor, peopleDialog, normalizeExternalUrl) { this.dialog = dialog; this.tr = tr; this.appearanceEditor = appearanceEditor; this.peopleDialog = peopleDialog; this.normalizeExternalUrl = normalizeExternalUrl; this.form = dialog.querySelector("form"); this.pageCombobox = new ComboBox(dialog.querySelector("#cmap-concept-page-combobox")); this.cmapCombobox = new ComboBox(dialog.querySelector("#cmap-concept-cmap-combobox")); this.tabs = new TabSet(dialog.querySelector(".cmap-concept-tabs")) .onSelect(() => { dialog.querySelector(".cmap-concept-dialog-body").scrollTop = 0; }); this.record = null; this.createContext = null; this.imageSource = ""; this.imageRead = Promise.resolve(); this.saveHandler = null; this.installEvents(); } onSave(handler) { this.saveHandler = handler; return this; } isOpen() { return this.dialog.open; } /** Open the dialog for an existing concept or sub-CMap placement. */ open(record, resources) { if (!record || record.kind === "phrase") return; this.record = record; this.createContext = null; this.imageSource = record.imageSource || ""; this.imageRead = Promise.resolve(); this.dialog.querySelector("#cmap-concept-dialog-title").textContent = this.tr("edit-concept", "Edit concept"); this.dialog.querySelector("#cmap-concept-label").value = record.label || ""; this.dialog.querySelector("#cmap-concept-synopsis").value = record.synopsis || ""; this.dialog.querySelector("#cmap-concept-aspects").value = (record.aspects || []).join(", "); const selectedPeople = (Array.isArray(record.tags) ? record.tags : []) .filter((tag) => tag && typeof tag === "object" && tag.type === "person") .map((tag) => tag.value); this.peopleDialog.showSelection(selectedPeople); this.setLinkFields(record, resources); this.appearanceEditor.showRecord(record); this.dialog.querySelector("#cmap-concept-image").value = ""; this.updateImagePreview(); this.tabs.select("content"); this.dialog.showModal(); const label = this.dialog.querySelector("#cmap-concept-label"); label.focus(); label.select(); } /** Open the dialog for a new concept at the supplied editor context. */ openNew(createContext, resources) { this.record = null; this.createContext = createContext; this.imageSource = ""; this.imageRead = Promise.resolve(); this.dialog.querySelector("#cmap-concept-dialog-title").textContent = this.tr("add-concept", "Add concept"); this.dialog.querySelector("#cmap-concept-label").value = "New concept"; this.dialog.querySelector("#cmap-concept-synopsis").value = ""; this.dialog.querySelector("#cmap-concept-aspects").value = ""; this.peopleDialog.showSelection([]); this.setLinkFields({ kind: "concept", pageSlug: null, cmapSlug: null }, resources); this.appearanceEditor.showNewConcept(); this.dialog.querySelector("#cmap-concept-image").value = ""; this.updateImagePreview(); this.tabs.select("content"); this.dialog.showModal(); const label = this.dialog.querySelector("#cmap-concept-label"); label.focus(); label.select(); } setLinkFields(record, resources) { const description = this.dialog.querySelector("#cmap-concept-description-page"); const descriptionLink = this.dialog.querySelector("#cmap-concept-description-link"); description.value = record.descriptionPageSlug || ""; descriptionLink.href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#"; descriptionLink.classList.toggle("hidden", !record.descriptionPageSlug); const externalUrl = this.dialog.querySelector("#cmap-concept-external-url"); externalUrl.value = record.externalUrl || ""; externalUrl.setCustomValidity(""); this.populatePageOptions(record, resources.pages); this.populateCmapOptions(record, resources.conceptMaps, resources.parentMapAvailable); } populatePageOptions(record, pages) { const sorted = [...pages].sort((a, b) => a.title.localeCompare(b.title)); const entries = sorted.map((page) => this.comboboxEntry(page)); if (record.pageSlug && !sorted.some((page) => page.slug === record.pageSlug)) { entries.push({ value: record.pageSlug, label: record.pageSlug }); } this.pageCombobox.setOptions(entries, record.pageSlug || ""); this.dialog.querySelector("#cmap-concept-page-row") .classList.toggle("hidden", record.kind === "submap"); } populateCmapOptions(record, conceptMaps, parentMapAvailable) { const entries = []; if (parentMapAvailable) { entries.push({ value: "__parent__", label: `↩ ${this.tr("parent-concept-map", "Parent concept map")}` }); } for (const conceptMap of [...conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) { entries.push(this.comboboxEntry(conceptMap)); } if (record.cmapSlug && !conceptMaps.some((item) => item.slug === record.cmapSlug)) { entries.push({ value: record.cmapSlug, label: record.cmapSlug }); } const selected = record.parentCmapLink ? "__parent__" : (record.cmapSlug || ""); this.cmapCombobox.setOptions(entries, selected); this.dialog.querySelector("#cmap-concept-cmap-row") .classList.toggle("hidden", record.kind === "submap"); } comboboxEntry(record) { return { value: record.slug, label: record.title || record.slug, description: record.slug }; } updateImagePreview() { const row = this.dialog.querySelector("#cmap-concept-image-preview-row"); const preview = this.dialog.querySelector("#cmap-concept-image-preview"); if (!this.imageSource) { row.classList.add("hidden"); preview.removeAttribute("src"); return; } preview.src = this.imageSource; row.classList.remove("hidden"); } readImage(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true }); reader.addEventListener("error", () => reject( reader.error || new Error("Image could not be read.")), { once: true }); reader.readAsDataURL(file); }); } /** Validate the form and return normalized concept values, or null on error. */ values() { const labelInput = this.dialog.querySelector("#cmap-concept-label"); const label = labelInput.value.trim(); if (!label) { this.tabs.select("content"); labelInput.focus(); return null; } const record = this.record; const pageInput = this.dialog.querySelector("#cmap-concept-page"); const cmapInput = this.dialog.querySelector("#cmap-concept-cmap"); const selectedPage = record && record.kind === "submap" ? "" : this.pageCombobox.value(); const selectedCmap = record && record.kind === "submap" ? "" : this.cmapCombobox.value(); const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage; if (selectedPage === null && !linkedPage) { this.tabs.select("content"); pageInput.setCustomValidity( this.tr("invalid-new-page", "Enter a page title or valid wiki address.")); pageInput.reportValidity(); return null; } if (selectedCmap === null) { this.tabs.select("content"); cmapInput.setCustomValidity(this.tr( "select-listed-concept-map", "Select a CMap from the list or clear the field.")); cmapInput.reportValidity(); return null; } const externalInput = this.dialog.querySelector("#cmap-concept-external-url"); const externalUrl = this.normalizeExternalUrl(externalInput.value); if (externalUrl === null) { this.tabs.select("content"); externalInput.setCustomValidity(this.tr( "invalid-external-web-page", "Enter a complete http or https web address.")); externalInput.reportValidity(); externalInput.focus(); return null; } const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page"); const descriptionText = descriptionInput.value.trim(); const descriptionPage = descriptionText ? newPageReference(descriptionText) : pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug); if (!descriptionPage) { this.tabs.select("content"); descriptionInput.setCustomValidity( this.tr("invalid-description-page", "Enter a valid description page address.")); descriptionInput.reportValidity(); return null; } const linkedCmapValue = selectedCmap || ""; return { label, synopsis: this.dialog.querySelector("#cmap-concept-synopsis").value, aspects: this.dialog.querySelector("#cmap-concept-aspects").value.split(",") .map((aspect) => aspect.trim()).filter(Boolean), personNames: this.peopleDialog.selectedNames(), descriptionPageSlug: descriptionPage, pageSlug: linkedPage || null, cmapSlug: linkedCmapValue && linkedCmapValue !== "__parent__" ? linkedCmapValue : null, externalUrl: externalUrl || null, parentCmapLink: linkedCmapValue === "__parent__", imageSource: this.imageSource, appearance: this.appearanceEditor.placementChanges( Boolean(record && record.kind === "submap")) }; } installEvents() { this.dialog.querySelector("#cmap-concept-page").addEventListener("change", () => { if (this.pageCombobox.value()) this.cmapCombobox.clear(); }); this.dialog.querySelector("#cmap-concept-cmap").addEventListener("change", () => { if (this.cmapCombobox.value()) this.pageCombobox.clear(); }); for (const id of ["#cmap-concept-page", "#cmap-concept-cmap"]) { this.dialog.querySelector(id).addEventListener("input", (event) => event.target.setCustomValidity("")); } this.dialog.querySelector("#cmap-concept-description-page") .addEventListener("input", (event) => event.target.setCustomValidity("")); this.dialog.querySelector("#cmap-concept-external-url") .addEventListener("input", (event) => event.target.setCustomValidity("")); this.dialog.querySelector("#cmap-concept-description-link") .addEventListener("click", () => this.dialog.close()); this.dialog.querySelector("#cmap-concept-cancel") .addEventListener("click", () => this.dialog.close()); this.dialog.querySelector("#cmap-concept-image").addEventListener("change", (event) => { const file = event.target.files && event.target.files[0]; if (!file) return; const record = this.record; this.imageRead = this.readImage(file) .then((imageSource) => { if (this.record !== record) return; this.imageSource = imageSource; this.updateImagePreview(); }) .catch((error) => { console.error(error); window.alert(error.message); }); }); this.dialog.querySelector("#cmap-concept-image-remove").addEventListener("click", () => { this.imageSource = ""; this.imageRead = Promise.resolve(); this.dialog.querySelector("#cmap-concept-image").value = ""; this.updateImagePreview(); }); this.form.addEventListener("submit", async (event) => { event.preventDefault(); await this.imageRead; const values = this.values(); if (!values || !this.saveHandler) return; const saved = await this.saveHandler(this.record, this.createContext, values); if (saved) this.dialog.close(); }); this.dialog.addEventListener("close", () => { this.record = null; this.createContext = null; this.imageSource = ""; this.imageRead = Promise.resolve(); }); } }