refactoring of cmaps, widgets, etc.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user