182 lines
6.9 KiB
JavaScript
182 lines
6.9 KiB
JavaScript
const STYLE_VALUE_KEYS = [
|
|
"backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle",
|
|
"synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
|
|
"synopsisFontStyle", "submapBackgroundColor", "submapBorderColor"
|
|
];
|
|
|
|
/** Return a detached value at the model boundary. */
|
|
function copy(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
/** Return a six-digit HTML colour or the supplied fallback. */
|
|
function cmapColorValue(value, fallback = "#f3f6f8") {
|
|
return /^#[0-9a-f]{6}$/i.test(value || "") ? value.toLowerCase() : fallback;
|
|
}
|
|
|
|
/** Convert a stored CSS font size to typographic points. */
|
|
function cmapFontSizeInPoints(value, baseSize = 11) {
|
|
const size = Number.parseFloat(value);
|
|
if (!Number.isFinite(size)) return baseSize;
|
|
if (/px$/i.test(value || "")) return size * 0.75;
|
|
if (/em$/i.test(value || "")) return size * baseSize;
|
|
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
|
|
return size;
|
|
}
|
|
|
|
/** Normalize an editable font size to the range accepted by the backend. */
|
|
function normalizedCmapFontSize(value, fallback) {
|
|
const size = Number(value);
|
|
const usableSize = Number.isFinite(size) ? size : fallback;
|
|
return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2));
|
|
}
|
|
|
|
/** Format a typographic point value for a form input. */
|
|
function displayCmapFontSize(value) {
|
|
return String(Math.round(value * 2) / 2);
|
|
}
|
|
|
|
/**
|
|
* Own the wiki-wide CMap styles and colour palette.
|
|
* The backend supplies the default style; this model validates UI changes
|
|
* against that style and exposes detached values to its consumers.
|
|
*/
|
|
export class CmapAppearance {
|
|
constructor(data) {
|
|
this.replace(data);
|
|
}
|
|
|
|
get styles() {
|
|
return copy(this._styles);
|
|
}
|
|
|
|
get palette() {
|
|
return [...this._palette];
|
|
}
|
|
|
|
get defaultValues() {
|
|
return copy(this._styles.find((style) => style.id === "default").values);
|
|
}
|
|
|
|
/** Replace the aggregate with a complete backend representation. */
|
|
replace(data) {
|
|
const styles = Array.isArray(data?.styles) ? data.styles : [];
|
|
const defaultStyle = styles.find((style) => style?.id === "default");
|
|
const palette = Array.isArray(data?.palette) ? data.palette : [];
|
|
const completeDefault = defaultStyle?.values &&
|
|
STYLE_VALUE_KEYS.every((key) => Object.hasOwn(defaultStyle.values, key));
|
|
if (!completeDefault || palette.length === 0) {
|
|
throw new TypeError("CMap appearance requires a default style and colour palette");
|
|
}
|
|
const seenIds = new Set();
|
|
this._styles = styles.map((style) => {
|
|
const id = typeof style?.id === "string" ? style.id.trim() : "";
|
|
const hasName = typeof style?.name === "string" && style.name.trim();
|
|
const hasNameKey = typeof style?.nameKey === "string" && style.nameKey.trim();
|
|
if (!/^[A-Za-z0-9_-]+$/.test(id) || seenIds.has(id) ||
|
|
(!hasName && !hasNameKey) || !style.values) {
|
|
throw new TypeError("CMap appearance contains an invalid style");
|
|
}
|
|
seenIds.add(id);
|
|
return {
|
|
id,
|
|
...(hasNameKey ? { nameKey: style.nameKey.trim() } : { name: style.name.trim() }),
|
|
protected: id === "default",
|
|
values: this.normalizeValues(style.values, defaultStyle.values)
|
|
};
|
|
});
|
|
this._palette = palette.map((color) => {
|
|
if (!/^#[0-9a-f]{6}$/i.test(color || "")) {
|
|
throw new TypeError("CMap appearance contains an invalid palette colour");
|
|
}
|
|
return color.toLowerCase();
|
|
});
|
|
return this;
|
|
}
|
|
|
|
/** Return the complete detached representation for backend storage. */
|
|
toData() {
|
|
return { styles: this.styles, palette: this.palette };
|
|
}
|
|
|
|
/** Normalize editable values using the backend-provided default style. */
|
|
normalizeValues(values, fallbackValues = null) {
|
|
if (!values || typeof values !== "object") return null;
|
|
const fallback = fallbackValues || this.defaultValues;
|
|
const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize);
|
|
return {
|
|
backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor),
|
|
textColor: cmapColorValue(values.textColor, fallback.textColor),
|
|
fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ?
|
|
values.fontFamily.trim() : fallback.fontFamily,
|
|
fontSize,
|
|
fontWeight: String(values.fontWeight) === "400" ? "400" : "700",
|
|
fontStyle: values.fontStyle === "italic" ? "italic" : "normal",
|
|
synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor),
|
|
synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ?
|
|
values.synopsisFontFamily.trim() : fallback.synopsisFontFamily,
|
|
synopsisFontSize: normalizedCmapFontSize(
|
|
values.synopsisFontSize, Math.max(6, fontSize - 2)),
|
|
synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400",
|
|
synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal",
|
|
submapBackgroundColor: cmapColorValue(
|
|
values.submapBackgroundColor, fallback.submapBackgroundColor),
|
|
submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor)
|
|
};
|
|
}
|
|
|
|
style(id) {
|
|
const style = this._styles.find((candidate) => candidate.id === id);
|
|
return style ? copy(style) : null;
|
|
}
|
|
|
|
matchingStyleId(values) {
|
|
const normalized = this.normalizeValues(values);
|
|
if (!normalized) return "";
|
|
const style = this._styles.find((candidate) =>
|
|
STYLE_VALUE_KEYS.every((key) => candidate.values[key] === normalized[key]));
|
|
return style?.id || "";
|
|
}
|
|
|
|
/** Insert or replace one non-default named style. */
|
|
putStyle(style) {
|
|
const name = typeof style?.name === "string" ? style.name.trim() : "";
|
|
const id = typeof style?.id === "string" ? style.id.trim() : "";
|
|
if (!/^[A-Za-z0-9_-]+$/.test(id) || id === "default" || !name) {
|
|
throw new TypeError("A custom CMap style requires an id and name");
|
|
}
|
|
const normalized = {
|
|
id,
|
|
name,
|
|
protected: false,
|
|
values: this.normalizeValues(style.values)
|
|
};
|
|
const existingIndex = this._styles.findIndex((candidate) => candidate.id === normalized.id);
|
|
if (existingIndex < 0) this._styles.push(normalized);
|
|
else this._styles.splice(existingIndex, 1, normalized);
|
|
return copy(normalized);
|
|
}
|
|
|
|
/** Delete a custom style and refuse deletion of protected styles. */
|
|
deleteStyle(id) {
|
|
const style = this._styles.find((candidate) => candidate.id === id);
|
|
if (!style || style.protected) return false;
|
|
this._styles = this._styles.filter((candidate) => candidate.id !== id);
|
|
return true;
|
|
}
|
|
|
|
setPaletteColor(index, color) {
|
|
if (!Number.isInteger(index) || index < 0 || index >= this._palette.length ||
|
|
!/^#[0-9a-f]{6}$/i.test(color || "")) return false;
|
|
this._palette[index] = color.toLowerCase();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export {
|
|
cmapColorValue,
|
|
cmapFontSizeInPoints,
|
|
displayCmapFontSize,
|
|
normalizedCmapFontSize
|
|
};
|