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