293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
}
|