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(); }); } } }