refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
@@ -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();
});
}
}