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
+398
View File
@@ -0,0 +1,398 @@
import {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize
} from "../model/appearance.js";
/**
* Present and edit CMap appearance in the concept dialog.
* The editor translates DOM changes to CmapAppearance operations and asks the
* repository to persist the complete aggregate after style or palette changes.
*/
export class CmapAppearanceEditor {
constructor(appearance, repository, tr) {
this.appearance = appearance;
this.repository = repository;
this.tr = tr;
this.$ = (id) => document.getElementById(id);
this.installColorPickers();
this.installStyleControls();
}
/** Fill appearance fields from one existing concept placement. */
showRecord(record) {
const fallback = this.appearance.defaultValues;
const titleFontSize = cmapFontSizeInPoints(record.fontSize, fallback.fontSize);
this.$("cmap-concept-background-label").textContent = record.kind === "submap" ?
this.tr("main-concept-background-color", "Main concept background color") :
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
this.apply({
backgroundColor: cmapColorValue(record.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(record.textColor, fallback.textColor),
fontFamily: record.fontFamily || fallback.fontFamily,
fontSize: titleFontSize,
fontWeight: String(record.fontWeight || fallback.fontWeight) === "400" ? "400" : "700",
fontStyle: record.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
record.synopsisTextColor || record.textColor, fallback.synopsisTextColor),
synopsisFontFamily: record.synopsisFontFamily || record.fontFamily || fallback.synopsisFontFamily,
synopsisFontSize: cmapFontSizeInPoints(
record.synopsisFontSize || "0.84em", titleFontSize),
synopsisFontWeight: String(
record.synopsisFontWeight || record.fontWeight || fallback.synopsisFontWeight) === "700" ?
"700" : "400",
synopsisFontStyle: (record.synopsisFontStyle || record.fontStyle) === "italic" ?
"italic" : "normal",
submapBackgroundColor: cmapColorValue(
record.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(record.submapBorderColor, fallback.submapBorderColor)
});
this.renderStyleOptions();
}
/** Fill appearance fields for a new ordinary concept using the model default. */
showNewConcept() {
this.$("cmap-concept-background-label").textContent =
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.add("hidden");
this.apply(this.appearance.defaultValues);
this.renderStyleOptions();
}
/** Return normalized placement values from the appearance form. */
placementChanges(isSubmap) {
const values = this.capture();
const changes = {
backgroundColor: values.backgroundColor,
textColor: values.textColor,
fontFamily: values.fontFamily,
fontSize: `${values.fontSize}pt`,
fontWeight: values.fontWeight,
fontStyle: values.fontStyle,
synopsisTextColor: values.synopsisTextColor,
synopsisFontFamily: values.synopsisFontFamily,
synopsisFontSize: `${values.synopsisFontSize}pt`,
synopsisFontWeight: values.synopsisFontWeight,
synopsisFontStyle: values.synopsisFontStyle
};
if (isSubmap) {
changes.submapBackgroundColor = values.submapBackgroundColor;
changes.submapBorderColor = values.submapBorderColor;
}
return changes;
}
capture() {
return this.appearance.normalizeValues({
backgroundColor: this.$("cmap-concept-background").value,
textColor: this.$("cmap-concept-text-color").value,
fontFamily: this.$("cmap-concept-font-family").value,
fontSize: this.$("cmap-concept-font-size").value,
fontWeight: this.$("cmap-concept-bold").checked ? "700" : "400",
fontStyle: this.$("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: this.$("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: this.$("cmap-concept-synopsis-font-family").value,
synopsisFontSize: this.$("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: this.$("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: this.$("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: this.$("cmap-submap-background").value,
submapBorderColor: this.$("cmap-submap-border").value
});
}
apply(values) {
const style = this.appearance.normalizeValues(values);
if (!style) return;
this.$("cmap-concept-background").value = style.backgroundColor;
this.$("cmap-concept-text-color").value = style.textColor;
this.selectFont(style.fontFamily);
this.$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
this.$("cmap-concept-bold").checked = style.fontWeight === "700";
this.$("cmap-concept-italic").checked = style.fontStyle === "italic";
this.$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
this.selectFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
this.$("cmap-concept-synopsis-font-size").value =
displayCmapFontSize(style.synopsisFontSize);
this.$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
this.$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
this.$("cmap-submap-background").value = style.submapBackgroundColor;
this.$("cmap-submap-border").value = style.submapBorderColor;
this.updateColorControls();
}
selectFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = this.$(selectId);
const value = fontFamily || this.appearance.defaultValues.fontFamily;
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
styleName(style) {
return style.nameKey ? this.tr(style.nameKey, style.nameKey) : style.name;
}
renderStyleOptions(selectedId = this.appearance.matchingStyleId(this.capture())) {
const styles = this.appearance.styles;
const usableId = styles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [
this.$("cmap-concept-quick-style"),
this.$("cmap-concept-style-preset")
]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = this.tr("custom-style", "Custom");
select.append(custom);
for (const style of styles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = this.styleName(style);
select.append(option);
}
select.value = usableId;
}
this.updateDeleteButton();
}
syncStyleSelection() {
const matchingId = this.appearance.matchingStyleId(this.capture());
this.$("cmap-concept-quick-style").value = matchingId;
this.$("cmap-concept-style-preset").value = matchingId;
this.updateDeleteButton();
}
updateDeleteButton() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
this.$("cmap-delete-style").disabled = !selected || selected.protected;
}
applySelectedStyle(event) {
const selectedId = event?.currentTarget?.value ??
this.$("cmap-concept-style-preset").value;
this.$("cmap-concept-quick-style").value = selectedId;
this.$("cmap-concept-style-preset").value = selectedId;
const style = this.appearance.style(selectedId);
if (style) this.apply(style.values);
this.updateDeleteButton();
}
newStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// A timestamp remains sufficient when randomUUID is unavailable.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async saveCurrentStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(this.tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(this.tr("style-name-required", "Enter a style name."));
return;
}
const existing = this.appearance.styles.find((style) =>
this.styleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(this.tr(
"replace-style-confirm", 'Replace the existing style "{name}"?')
.replace("{name}", this.styleName(existing)))) return;
const previousAppearance = this.appearance.toData();
const replacement = this.appearance.putStyle({
id: existing?.id || this.newStyleId(),
name: cleanName,
values: this.capture()
});
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions(replacement.id);
}
async deleteSelectedStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(this.tr(
"delete-style-confirm", 'Delete style "{name}"?')
.replace("{name}", this.styleName(selected)))) return;
const previousAppearance = this.appearance.toData();
this.appearance.deleteStyle(selected.id);
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions();
}
async store() {
try {
await this.repository.save(this.appearance);
return true;
} catch (error) {
console.warn("The CMap appearance could not be stored in the database.", error);
window.alert(this.tr(
"cmap-appearance-storage-failed",
"The CMap appearance could not be stored in the wiki database."));
return false;
}
}
openNativeColorPicker(initialColor, onInput, onCommit = null) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => {
if (onCommit) onCommit(cmapColorValue(picker.value, "#ffffff"));
window.setTimeout(cleanup, 0);
}, { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
updateColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
updateColorControls() {
for (const input of this.$("cmap-concept-panel-appearance").querySelectorAll(
".cmap-color-input")) {
this.updateColorControl(input);
}
}
updatePaletteChoices(index, color) {
for (const choice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
}
installColorPickers() {
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = this.tr(
"color-swatch-help", "Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
this.appearance.palette.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
this.updatePaletteChoice(choice, color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = this.appearance.palette[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
const previousAppearance = this.appearance.toData();
const updateColor = (newColor) => {
this.appearance.setPaletteColor(index, newColor);
this.updatePaletteChoices(index, newColor);
};
this.openNativeColorPicker(this.appearance.palette[index], updateColor, async () => {
if (!await this.store()) {
this.appearance.replace(previousAppearance);
this.updatePaletteChoices(index, previousAppearance.palette[index]);
}
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
this.openNativeColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => this.updateColorControl(input));
this.updateColorControl(input);
}
document.addEventListener("pointerdown", (event) => {
if (event.target.closest(".cmap-color-control")) return;
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
});
}
updatePaletteChoice(choice, color) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
installStyleControls() {
this.$("cmap-concept-quick-style").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-concept-style-preset").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-save-style").addEventListener("click", () => this.saveCurrentStyle());
this.$("cmap-delete-style").addEventListener("click", () => this.deleteSelectedStyle());
for (const eventName of ["input", "change"]) {
this.$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) this.syncStyleSelection();
});
}
}
}