improve notepad features and add drawing canvas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-29 13:51:42 +02:00
parent e73d754082
commit de1b8a5638
20 changed files with 1732 additions and 141 deletions

View file

@ -1,6 +1,18 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e" };
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
const NOTEPAD_RGB_COLOR_MAP = {
"rgb(245, 247, 255)": "#f5f7ff",
"rgb(180, 189, 211)": "#b4bdd3",
"rgb(246, 196, 83)": "#f6c453",
"rgb(34, 211, 238)": "#22d3ee",
"rgb(139, 92, 246)": "#8b5cf6",
"rgb(217, 70, 239)": "#d946ef",
"rgb(16, 20, 38)": "#101426",
"rgb(32, 39, 69)": "#202745"
};
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
@ -119,6 +131,136 @@ function parsePositiveInt(value, fallback = 1) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function decodeBasicEntities(value) {
return String(value || "")
.replace(/&nbsp;/gi, " ")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/gi, "&");
}
function normalizeStyleAttribute(value) {
const declarations = String(value || "")
.split(";")
.map((item) => item.trim())
.filter(Boolean);
const allowed = [];
declarations.forEach((declaration) => {
const [property, rawValue] = declaration.split(":").map((item) => item?.trim().toLowerCase());
if (!["color", "background-color"].includes(property)) return;
const color = NOTEPAD_RGB_COLOR_MAP[rawValue] || rawValue;
if (!NOTEPAD_ALLOWED_COLORS.has(color)) return;
allowed.push(`${property}: ${color}`);
});
return allowed.join("; ");
}
export function sanitizeNotepadHtml(value) {
const source = String(value || "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "");
let html = "";
let cursor = 0;
source.replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttributes, offset) => {
html += escapeHtml(source.slice(cursor, offset));
cursor = offset + match.length;
const tag = rawTag.toLowerCase();
if (!NOTEPAD_ALLOWED_TAGS.has(tag)) return "";
if (match.startsWith("</")) {
if (tag !== "br") html += `</${tag}>`;
return "";
}
if (tag === "br") {
html += "<br>";
return "";
}
const styleMatch = String(rawAttributes || "").match(/\sstyle=(?:"([^"]*)"|'([^']*)')/i);
const style = normalizeStyleAttribute(styleMatch?.[1] || styleMatch?.[2] || "");
html += style ? `<${tag} style="${style}">` : `<${tag}>`;
return "";
});
html += escapeHtml(source.slice(cursor));
return html.trim();
}
function textToNotepadHtml(value) {
return String(value || "")
.split(/\r?\n/)
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
.join("");
}
function notepadHtmlToText(value) {
const withBreaks = String(value || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|h3|h4|li)>/gi, "\n")
.replace(/<li[^>]*>/gi, "- ");
return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, ""))
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function normalizeIsoDate(value) {
const text = String(value || "").trim();
if (!text) return "";
const timestamp = Date.parse(text);
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "";
}
function normalizeDrawingPoint(point, options = {}) {
const x = Number(point?.x);
const y = Number(point?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const max = Number(options.max);
return {
x: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(x * 100) / 100)),
y: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(y * 100) / 100))
};
}
function normalizeDrawingStroke(stroke, options = {}) {
const points = (Array.isArray(stroke?.points) ? stroke.points : []).map((point) => normalizeDrawingPoint(point, options)).filter(Boolean);
if (points.length < 2) return null;
const color = NOTEPAD_ALLOWED_COLORS.has(String(stroke?.color || "").toLowerCase()) ? String(stroke.color).toLowerCase() : "#f6c453";
const width = Math.min(24, Math.max(1, Number(stroke?.width) || 4));
return {
id: stroke?.id || uid("stroke"),
color,
width: Math.round(width * 10) / 10,
points
};
}
export function normalizeDrawingData(data, options = {}) {
return {
strokes: (Array.isArray(data?.strokes) ? data.strokes : []).map((stroke) => normalizeDrawingStroke(stroke, options)).filter(Boolean)
};
}
export function normalizeNotepadData(data) {
const source = data && typeof data === "object" ? data : {};
const legacyText = String(source.text || "");
const html = sanitizeNotepadHtml(source.html ? source.html : textToNotepadHtml(legacyText));
const text = notepadHtmlToText(html) || legacyText.trim();
const updatedAt = normalizeIsoDate(source.updatedAt);
const drawingMode = source.drawingMode === "permanent" ? "permanent" : "temporary";
const drawings = normalizeDrawingData(source.drawings);
return { html, text, updatedAt, drawingMode, drawings };
}
export function clampQty(value, target) {
const parsed = Number.parseInt(value, 10);
const safeValue = Number.isFinite(parsed) ? parsed : 0;
@ -413,7 +555,7 @@ export function normalizeImageAnnotationData(data) {
y: clampPercent(marker?.y),
label: String(marker?.label || "").trim()
}));
return { image, markers };
return { image, markers, drawings: normalizeDrawingData(data?.drawings, { max: 100 }) };
}
function compactChecklistItemForStorage(item) {
@ -438,8 +580,14 @@ function compactChecklistSectionForStorage(section) {
export function compactModuleDataForStorage(type, value) {
if (type === "notepad") {
const text = String(value?.text || "");
return text ? { text } : null;
const normalized = normalizeNotepadData(value);
const compact = {};
if (normalized.html && normalized.text) compact.html = normalized.html;
if (normalized.text) compact.text = normalized.text;
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return Object.keys(compact).length ? compact : null;
}
if (type === "checklist") {
const normalized = normalizeChecklistData(value);
@ -465,7 +613,7 @@ export function compactModuleDataForStorage(type, value) {
if (type === "imageAnnotation") {
const normalized = normalizeImageAnnotationData(value);
if (!normalized.image) return null;
return {
const compact = {
image: normalized.image,
markers: normalized.markers.map((marker) => {
const compact = { id: marker.id, x: marker.x, y: marker.y };
@ -473,6 +621,8 @@ export function compactModuleDataForStorage(type, value) {
return compact;
})
};
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return compact;
}
if (type === "links") {
const links = normalizeLinksData(value).links.map((link) => {
@ -604,10 +754,16 @@ function remapModuleDataForExport(type, data, nextId) {
}
if (type === "imageAnnotation") {
return {
const remapped = {
...compact,
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
};
if (compact.drawings?.strokes) {
remapped.drawings = {
strokes: compact.drawings.strokes.map((stroke) => ({ ...stroke, id: nextId("stroke") }))
};
}
return remapped;
}
if (type === "links") {