399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
}
|
|
}
|