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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { StatusField } from "../../../widgets/status-field.js";
|
||||
|
||||
/**
|
||||
* Run the user-facing Markdown and JSON export actions for one CMap.
|
||||
*
|
||||
* Export generation is supplied by the workspace. This controller owns export
|
||||
* options, progress messages, clipboard handling and browser downloads.
|
||||
*/
|
||||
export class CmapExportDialog {
|
||||
constructor(dialog, tr, buildMarkdown, buildJson) {
|
||||
this.dialog = dialog;
|
||||
this.tr = tr;
|
||||
this.buildMarkdown = buildMarkdown;
|
||||
this.buildJson = buildJson;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.depth = dialog.querySelector("#cmap-export-depth");
|
||||
this.pages = dialog.querySelector("#cmap-export-pages");
|
||||
this.status = new StatusField(dialog.querySelector("#cmap-export-status"));
|
||||
this.conceptMapSlug = "concept-map";
|
||||
|
||||
dialog.querySelector("#cmap-export-cancel").addEventListener("click", () => dialog.close());
|
||||
dialog.querySelector("#cmap-export-copy").addEventListener("click", () => {
|
||||
this.exportMarkdown(false);
|
||||
});
|
||||
dialog.querySelector("#cmap-export-json").addEventListener("click", () => {
|
||||
this.exportJson();
|
||||
});
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.exportMarkdown(true);
|
||||
});
|
||||
}
|
||||
|
||||
open(conceptMapSlug) {
|
||||
this.conceptMapSlug = conceptMapSlug;
|
||||
this.status.clear();
|
||||
this.dialog.showModal();
|
||||
this.depth.focus();
|
||||
}
|
||||
|
||||
options() {
|
||||
return {
|
||||
depth: Math.max(0, Math.min(10, Number(this.depth.value) || 0)),
|
||||
includeWikiPages: this.pages.checked
|
||||
};
|
||||
}
|
||||
|
||||
async exportJson() {
|
||||
this.status.set(this.tr("preparing-cmap-json", "Preparing complete CMap JSON…"));
|
||||
try {
|
||||
const bundle = await this.buildJson(this.options().depth);
|
||||
this.download(`${JSON.stringify(bundle, null, 2)}\n`,
|
||||
`${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8");
|
||||
this.status.set(this.tr("cmap-json-downloaded", "CMap JSON downloaded."));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status.set(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exportMarkdown(download) {
|
||||
this.status.set(this.tr("preparing-markdown-export", "Preparing Markdown export…"));
|
||||
try {
|
||||
const markdown = await this.buildMarkdown(this.options());
|
||||
if (download) {
|
||||
this.download(markdown, `${this.conceptMapSlug}-report.md`,
|
||||
"text/markdown;charset=utf-8");
|
||||
this.status.set(this.tr("markdown-export-downloaded", "Markdown export downloaded."));
|
||||
} else {
|
||||
await this.copy(markdown);
|
||||
this.status.set(this.tr("markdown-export-copied", "Markdown export copied."));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status.set(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
download(text, filename, contentType) {
|
||||
const blob = new Blob([text], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
async copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (_error) {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "-10000px";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Present and maintain the stored history of one CMap.
|
||||
*
|
||||
* History records come from CmapRepository. Loading a version is delegated to
|
||||
* the workspace because it controls unsaved-change transitions and the editor.
|
||||
*/
|
||||
export class CmapHistoryDialog {
|
||||
constructor(dialog, repository, tr, pageDisplayDate, can, loadVersion, showStatus) {
|
||||
this.dialog = dialog;
|
||||
this.repository = repository;
|
||||
this.tr = tr;
|
||||
this.pageDisplayDate = pageDisplayDate;
|
||||
this.can = can;
|
||||
this.loadVersion = loadVersion;
|
||||
this.showStatus = showStatus;
|
||||
this.list = dialog.querySelector("#cmap-history-list");
|
||||
|
||||
dialog.querySelector("#cmap-history-close").addEventListener("click", () => dialog.close());
|
||||
}
|
||||
|
||||
async open(conceptMap) {
|
||||
const versions = await this.repository.history(conceptMap);
|
||||
this.list.replaceChildren();
|
||||
if (!versions.length) this.showEmptyMessage();
|
||||
for (const version of versions) this.list.append(this.versionRow(conceptMap, version));
|
||||
this.dialog.showModal();
|
||||
}
|
||||
|
||||
showEmptyMessage() {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted cmap-history-empty";
|
||||
empty.textContent = this.tr(
|
||||
"no-concept-map-history", "No snapshots or manual saves yet.");
|
||||
this.list.append(empty);
|
||||
}
|
||||
|
||||
versionRow(conceptMap, version) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmap-history-row";
|
||||
const label = document.createElement("div");
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = `${this.tr("version", "Version")} ${version.version} — ${version.title}`;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "muted";
|
||||
meta.textContent = `${this.pageDisplayDate(version.createdAt)} · ${version.author} · ${this.versionSummary(version)}`;
|
||||
label.append(heading, document.createElement("br"), meta);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "cmap-history-actions";
|
||||
actions.append(this.loadButton(conceptMap, version));
|
||||
if (this.can("editor")) actions.append(this.deleteButton(conceptMap, version, row));
|
||||
row.append(label, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
versionSummary(version) {
|
||||
const knownSummaries = {
|
||||
create: this.tr("concept-map-created-version", "CMap created"),
|
||||
rename: this.tr("concept-map-renamed-version", "CMap renamed")
|
||||
};
|
||||
let summary = knownSummaries[version.action] || version.summary;
|
||||
if (version.action === "snapshot") {
|
||||
const snapshotLabel = this.tr("snapshot", "Snapshot");
|
||||
if (version.summary === "Current state when CMap history was enabled") {
|
||||
summary = this.tr("concept-map-initial-version", "Initial available version");
|
||||
} else {
|
||||
summary = version.summary === snapshotLabel ?
|
||||
snapshotLabel : `${snapshotLabel} — ${version.summary}`;
|
||||
}
|
||||
}
|
||||
if (version.summary === "Automatic save") {
|
||||
summary = this.tr("automatic-save", "Automatic save");
|
||||
}
|
||||
if (version.summary === "Manual save") summary = this.tr("manual-save", "Manual save");
|
||||
return summary;
|
||||
}
|
||||
|
||||
loadButton(conceptMap, version) {
|
||||
const load = document.createElement("button");
|
||||
load.type = "button";
|
||||
load.textContent = version.version === conceptMap.currentVersion ?
|
||||
this.tr("current-version", "Current") : this.tr("load-version", "Load version");
|
||||
load.disabled = version.version === conceptMap.currentVersion;
|
||||
load.addEventListener("click", () => {
|
||||
this.dialog.close();
|
||||
this.loadVersion(version.version);
|
||||
});
|
||||
return load;
|
||||
}
|
||||
|
||||
deleteButton(conceptMap, version, row) {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = this.tr("delete-history-item", "Delete");
|
||||
remove.addEventListener("click", async () => {
|
||||
const question = this.tr(
|
||||
"delete-concept-map-history-confirm",
|
||||
"Delete CMap history version {version}? The current CMap will not be changed.")
|
||||
.replace("{version}", String(version.version));
|
||||
if (!window.confirm(question)) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await this.repository.deleteVersion(conceptMap, version.version);
|
||||
row.remove();
|
||||
if (!this.list.querySelector(".cmap-history-row")) this.showEmptyMessage();
|
||||
this.showStatus(
|
||||
this.tr("concept-map-history-deleted", "History item deleted"), true);
|
||||
} catch (error) {
|
||||
remove.disabled = false;
|
||||
this.showStatus(error.message);
|
||||
}
|
||||
});
|
||||
return remove;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Edit CMap metadata without knowing how the active CMap is persisted.
|
||||
*
|
||||
* The controller owns field population and validation. Its save handler
|
||||
* receives normalized metadata and reports success with a boolean result.
|
||||
*/
|
||||
export class CmapMetadataDialog {
|
||||
constructor(dialog, tr, normalizePageReference) {
|
||||
this.dialog = dialog;
|
||||
this.tr = tr;
|
||||
this.normalizePageReference = normalizePageReference;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.summary = dialog.querySelector("#cmap-metadata-summary");
|
||||
this.tags = dialog.querySelector("#cmap-metadata-tags");
|
||||
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
|
||||
this.saveHandler = null;
|
||||
|
||||
dialog.querySelector("#cmap-metadata-cancel")
|
||||
.addEventListener("click", () => dialog.close());
|
||||
this.explanationPage.addEventListener("input", () =>
|
||||
this.explanationPage.setCustomValidity(""));
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.save().catch((error) => window.alert(error.message));
|
||||
});
|
||||
}
|
||||
|
||||
onSave(handler) {
|
||||
this.saveHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
open(metadata) {
|
||||
this.summary.value = metadata.summary || "";
|
||||
this.tags.value = (metadata.tags || []).join(", ");
|
||||
this.explanationPage.value = metadata.explanationPageSlug || "";
|
||||
this.explanationPage.setCustomValidity("");
|
||||
this.dialog.showModal();
|
||||
this.summary.focus();
|
||||
}
|
||||
|
||||
metadata() {
|
||||
const explanationInput = this.explanationPage.value.trim();
|
||||
const explanationPageSlug = explanationInput ?
|
||||
this.normalizePageReference(explanationInput) : "";
|
||||
if (explanationInput && !explanationPageSlug) {
|
||||
this.explanationPage.setCustomValidity(
|
||||
this.tr("invalid-description-page", "Enter a valid description page address."));
|
||||
this.explanationPage.reportValidity();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
summary: this.summary.value.trim(),
|
||||
explanationPageSlug
|
||||
};
|
||||
}
|
||||
|
||||
async save() {
|
||||
const metadata = this.metadata();
|
||||
if (!metadata || !this.saveHandler) return false;
|
||||
const saved = await this.saveHandler(metadata);
|
||||
if (saved) this.dialog.close();
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Manage the shared people list used by the concept picker and people dialog.
|
||||
*
|
||||
* The controller loads and updates people through the wiki API, renders both
|
||||
* views from the same collection and exposes only the selected person names to
|
||||
* the concept editor.
|
||||
*/
|
||||
export class CmapPeopleDialog {
|
||||
constructor(dialog, picker, repository, tr) {
|
||||
this.dialog = dialog;
|
||||
this.picker = picker;
|
||||
this.repository = repository;
|
||||
this.tr = tr;
|
||||
this.people = [];
|
||||
this.options = picker.querySelector("#cmap-person-tag-options");
|
||||
this.managementList = dialog.querySelector("#cmap-people-list");
|
||||
this.conceptInput = picker.querySelector("#cmap-person-new-name");
|
||||
this.managementInput = dialog.querySelector("#cmap-people-new-name");
|
||||
|
||||
picker.querySelector("#cmap-person-add").addEventListener("click", () => {
|
||||
this.createPerson(this.conceptInput, true)
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.conceptInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
this.createPerson(this.conceptInput, true)
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
dialog.querySelector("#cmap-people-add").addEventListener("click", () => {
|
||||
this.createPerson(this.managementInput, false)
|
||||
.then(() => this.renderManagement())
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.managementInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
this.createPerson(this.managementInput, false)
|
||||
.then(() => this.renderManagement())
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.options.addEventListener("change", () => this.renderOptions(this.selectedNames()));
|
||||
dialog.querySelector("#cmap-people-close").addEventListener("click", () => dialog.close());
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.people = await this.repository.all();
|
||||
return this.people;
|
||||
}
|
||||
|
||||
selectedNames() {
|
||||
return Array.from(this.options.querySelectorAll("input[type='checkbox']:checked"))
|
||||
.map((input) => input.dataset.personName)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
showSelection(selectedNames = []) {
|
||||
this.renderOptions(selectedNames);
|
||||
this.picker.open = false;
|
||||
}
|
||||
|
||||
/** Render the picker while retaining inactive or missing selected people. */
|
||||
renderOptions(selectedNames = []) {
|
||||
const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase()));
|
||||
this.options.replaceChildren();
|
||||
const visiblePeople = this.people.filter((person) =>
|
||||
person.active || selected.has(String(person.name).toLocaleLowerCase()));
|
||||
for (const person of visiblePeople) {
|
||||
this.options.append(this.personOption(person.name, person.active, selected));
|
||||
}
|
||||
|
||||
const knownNames = new Set(
|
||||
this.people.map((person) => String(person.name).toLocaleLowerCase()));
|
||||
for (const selectedName of selectedNames) {
|
||||
if (!knownNames.has(String(selectedName).toLocaleLowerCase())) {
|
||||
this.options.append(this.personOption(selectedName, false, selected));
|
||||
}
|
||||
}
|
||||
if (!this.options.childElementCount) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "muted";
|
||||
empty.textContent = this.tr("no-active-people", "No active people yet.");
|
||||
this.options.append(empty);
|
||||
}
|
||||
|
||||
const summary = this.picker.querySelector("summary");
|
||||
summary.textContent = selected.size ?
|
||||
this.tr("people-selected", "{count} people selected")
|
||||
.replace("{count}", String(selected.size)) :
|
||||
this.tr("select-people", "Select people");
|
||||
}
|
||||
|
||||
personOption(name, active, selected) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "cmap-person-tag-option";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.dataset.personName = name;
|
||||
checkbox.checked = selected.has(String(name).toLocaleLowerCase());
|
||||
const text = document.createElement("span");
|
||||
text.textContent = active ? name : `${name} (${this.tr("inactive", "inactive")})`;
|
||||
label.append(checkbox, text);
|
||||
return label;
|
||||
}
|
||||
|
||||
async createPerson(input, selectInConceptDialog) {
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
input.focus();
|
||||
return null;
|
||||
}
|
||||
const selected = selectInConceptDialog ? this.selectedNames() : [];
|
||||
const person = await this.repository.create(name);
|
||||
input.value = "";
|
||||
await this.load();
|
||||
if (selectInConceptDialog) this.renderOptions([...selected, person.name]);
|
||||
return person;
|
||||
}
|
||||
|
||||
renderManagement() {
|
||||
this.managementList.replaceChildren();
|
||||
for (const person of this.people) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmap-person-admin-row";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = person.name;
|
||||
const activeLabel = document.createElement("label");
|
||||
const active = document.createElement("input");
|
||||
active.type = "checkbox";
|
||||
active.checked = Boolean(person.active);
|
||||
const activeText = document.createElement("span");
|
||||
activeText.textContent = this.tr("active", "Active");
|
||||
activeLabel.append(active, activeText);
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = this.tr("save", "Save");
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
await this.repository.update(person, active.checked);
|
||||
await this.load();
|
||||
this.renderManagement();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(name, activeLabel, save);
|
||||
this.managementList.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.load();
|
||||
this.renderManagement();
|
||||
this.dialog.showModal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Ask how pending CMap changes should be handled before navigation.
|
||||
*
|
||||
* Each call to choose resolves once with "save", "discard" or "cancel".
|
||||
* Saving and discarding remain workspace responsibilities because they change
|
||||
* the active editor and persistence state.
|
||||
*/
|
||||
export class CmapUnsavedDialog {
|
||||
constructor(dialog) {
|
||||
this.dialog = dialog;
|
||||
this.resolveChoice = null;
|
||||
|
||||
dialog.querySelector("#cmap-unsaved-save")
|
||||
.addEventListener("click", () => this.finish("save"));
|
||||
dialog.querySelector("#cmap-unsaved-discard")
|
||||
.addEventListener("click", () => this.finish("discard"));
|
||||
dialog.querySelector("#cmap-unsaved-cancel")
|
||||
.addEventListener("click", () => this.finish("cancel"));
|
||||
dialog.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
this.finish("cancel");
|
||||
});
|
||||
}
|
||||
|
||||
choose() {
|
||||
if (this.resolveChoice) return Promise.resolve("cancel");
|
||||
this.dialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
this.resolveChoice = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
chooseSave() {
|
||||
this.finish("save");
|
||||
}
|
||||
|
||||
finish(choice) {
|
||||
const resolve = this.resolveChoice;
|
||||
if (!resolve) return;
|
||||
this.resolveChoice = null;
|
||||
this.dialog.close();
|
||||
resolve(choice);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user