refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user