diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 2f7f18a..91fea27 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -174,6 +174,9 @@ test("toolbox module registry and modules expose expected behavior", async () => const textExchangeHook = await readFile("website/src/features/toolboxes/modules/useTextExchange.js", "utf8"); const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8"); const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8"); + const textImportShared = await readFile("website/src/features/toolboxes/modules/textImport/shared.js", "utf8"); + const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8"); + const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULES/); @@ -483,16 +486,18 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerParts, /export function CountdownControls/); assert.match(timerParts, /export function CountdownList/); assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/); - assert.match(textImport, /export function parseColonImportLines/); + assert.match(textImport, /export \{ parseColonImportLines \}/); assert.match(textImport, /export function exportModuleText/); assert.match(textImport, /export function importModuleText/); assert.match(textImport, /export function hasTextExchangeContent/); - assert.match(textImport, /line\.indexOf\(":"\)/); - assert.match(textImport, /exportCalendarText/); - assert.match(textImport, /importCalendarText/); - assert.match(textImport, /exportGoalTreeText/); - assert.match(textImport, /importGoalTreeText/); - assert.match(textImport, /CALENDAR_WEEKDAYS/); + assert.match(textImport, /TEXT_EXCHANGE_HANDLERS/); + assert.match(textImportShared, /export function parseColonImportLines/); + assert.match(textImportShared, /line\.indexOf\(":"\)/); + assert.match(textImportTime, /export function exportCalendarText/); + assert.match(textImportTime, /export function importCalendarText/); + assert.match(textImportPlanning, /export function exportGoalTreeText/); + assert.match(textImportPlanning, /export function importGoalTreeText/); + assert.match(textImportTime, /CALENDAR_WEEKDAYS/); assert.match(imageViewer, /image-viewer-media/); assert.match(imageViewer, /image-viewer-image-frame/); assert.match(imageViewer, /image-viewer-marker/); diff --git a/website/src/features/toolboxes/modules/textImport.js b/website/src/features/toolboxes/modules/textImport.js index 5bf7570..429e954 100644 --- a/website/src/features/toolboxes/modules/textImport.js +++ b/website/src/features/toolboxes/modules/textImport.js @@ -1,1023 +1,122 @@ -// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox. -import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "./calendarUtils.js"; -import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js"; -import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js"; +// Rôle : expose la façade publique d'import/export texte des outils toolbox. +import { + exportCalculatorText, + exportChecklistText, + exportCountersText, + exportLinksText, + exportNotepadText, + exportTableTsv, + importCalculatorText, + importChecklistText, + importCountersText, + importLinksText, + importNotepadText, + importTableTsv +} from "./textImport/basic.js"; +import { exportCombosText, importCombosText } from "./textImport/combos.js"; +import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textImport/equipment.js"; +import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js"; +import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js"; +import { fail, parseColonImportLines } from "./textImport/shared.js"; +import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js"; -export function parseColonImportLines(text) { - return String(text || "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const separatorIndex = line.indexOf(":"); - if (separatorIndex === -1) return { label: line, value: "" }; - return { - label: line.slice(0, separatorIndex).trim(), - value: line.slice(separatorIndex + 1).trim() - }; - }) - .filter((entry) => entry.label); -} +export { parseColonImportLines }; -const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([ - "notepad", - "checklist", - "links", - "counters", - "combos", - "calculator", - "table", - "timer", - "calendar", - "taskPlanner", - "goalTree", - "equipmentPlanner", - "images", - "imageAnnotation" -]); -const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); -const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); -const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]); -const ALERT_MODES = new Set(["off", "visible", "site"]); -const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES); -const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS); -const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS); -const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY); -const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES); -const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]; -const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i; +const TEXT_EXCHANGE_HANDLERS = { + notepad: { + hasContent: (data) => Boolean(data?.text || data?.html || data?.drawings?.strokes?.length), + exportText: exportNotepadText, + importText: importNotepadText + }, + checklist: { + hasContent: (data) => Boolean(data?.sections?.some((section) => section.items?.length)), + exportText: exportChecklistText, + importText: importChecklistText + }, + links: { + hasContent: (data) => Boolean(data?.links?.length), + exportText: exportLinksText, + importText: importLinksText + }, + counters: { + hasContent: (data) => Boolean(data?.counters?.length), + exportText: exportCountersText, + importText: importCountersText + }, + combos: { + hasContent: (data) => Boolean(data?.combos?.length), + exportText: exportCombosText, + importText: importCombosText + }, + calculator: { + hasContent: (data) => Boolean(data?.entries?.length || data?.scrollResults), + exportText: exportCalculatorText, + importText: importCalculatorText + }, + table: { + hasContent: (data) => Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length), + exportText: exportTableTsv, + importText: importTableTsv + }, + timer: { + hasContent: (data) => Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs), + exportText: exportTimerText, + importText: importTimerText + }, + calendar: { + hasContent: (data) => Boolean(data?.events?.length), + exportText: exportCalendarText, + importText: importCalendarText + }, + taskPlanner: { + hasContent: (data) => Boolean(data?.tasks?.length), + exportText: exportTaskPlannerText, + importText: importTaskPlannerText + }, + goalTree: { + hasContent: (data) => Boolean(data?.nodes?.length), + exportText: exportGoalTreeText, + importText: importGoalTreeText + }, + equipmentPlanner: { + hasContent: (data) => Boolean(data?.equipments?.length), + exportText: exportEquipmentPlannerText, + importText: importEquipmentPlannerText + }, + images: { + hasContent: (data) => Boolean(data?.images?.length), + exportText: exportImagesText, + importText: importImagesText + }, + imageAnnotation: { + hasContent: (data) => Boolean(data?.image), + exportText: exportImageAnnotationText, + importText: importImageAnnotationText + } +}; export function supportsTextExchange(type) { - return SUPPORTED_TEXT_EXCHANGE_TYPES.has(type); + return Boolean(TEXT_EXCHANGE_HANDLERS[type]); } export function hasTextExchangeContent(type, data) { - if (type === "notepad") return Boolean(data?.text || data?.html || data?.drawings?.strokes?.length); - if (type === "checklist") return Boolean(data?.sections?.some((section) => section.items?.length)); - if (type === "links") return Boolean(data?.links?.length); - if (type === "counters") return Boolean(data?.counters?.length); - if (type === "combos") return Boolean(data?.combos?.length); - if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults); - if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length); - if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs); - if (type === "calendar") return Boolean(data?.events?.length); - if (type === "taskPlanner") return Boolean(data?.tasks?.length); - if (type === "goalTree") return Boolean(data?.nodes?.length); - if (type === "equipmentPlanner") return Boolean(data?.equipments?.length); - if (type === "images") return Boolean(data?.images?.length); - if (type === "imageAnnotation") return Boolean(data?.image); - return false; + return Boolean(TEXT_EXCHANGE_HANDLERS[type]?.hasContent(data)); } export function exportModuleText(type, data) { - if (type === "notepad") return exportNotepadText(data); - if (type === "checklist") return exportChecklistText(data); - if (type === "links") return exportLinksText(data); - if (type === "counters") return exportCountersText(data); - if (type === "combos") return exportCombosText(data); - if (type === "calculator") return exportCalculatorText(data); - if (type === "table") return exportTableTsv(data); - if (type === "timer") return exportTimerText(data); - if (type === "calendar") return exportCalendarText(data); - if (type === "taskPlanner") return exportTaskPlannerText(data); - if (type === "goalTree") return exportGoalTreeText(data); - if (type === "equipmentPlanner") return exportEquipmentPlannerText(data); - if (type === "images") return exportImagesText(data); - if (type === "imageAnnotation") return exportImageAnnotationText(data); - return ""; + return TEXT_EXCHANGE_HANDLERS[type]?.exportText(data) || ""; } export function importModuleText(type, text, context) { try { const value = String(text || ""); if (!value.trim()) return fail("Le texte à importer est vide."); - let data = null; - if (type === "notepad") data = importNotepadText(value, context); - else if (type === "checklist") data = importChecklistText(value, context); - else if (type === "links") data = importLinksText(value, context); - else if (type === "counters") data = importCountersText(value, context); - else if (type === "combos") data = importCombosText(value, context); - else if (type === "calculator") data = importCalculatorText(value, context); - else if (type === "table") data = importTableTsv(value); - else if (type === "timer") data = importTimerText(value, context); - else if (type === "calendar") data = importCalendarText(value, context); - else if (type === "taskPlanner") data = importTaskPlannerText(value, context); - else if (type === "goalTree") data = importGoalTreeText(value, context); - else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context); - else if (type === "images") data = importImagesText(value, context); - else if (type === "imageAnnotation") data = importImageAnnotationText(value, context); - else return fail("Cet outil ne supporte pas l'import texte."); - if (!hasTextExchangeContent(type, data)) return fail("Aucun contenu valide détecté."); + const handler = TEXT_EXCHANGE_HANDLERS[type]; + if (!handler) return fail("Cet outil ne supporte pas l'import texte."); + const data = handler.importText(value, context); + if (!handler.hasContent(data)) return fail("Aucun contenu valide détecté."); return { ok: true, data }; } catch (error) { return fail(error?.message || "Format invalide."); } } - -function fail(error) { - return { ok: false, error }; -} - -function splitLines(text) { - return String(text || "").split(/\r?\n/); -} - -function parseMetaLine(line) { - const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/); - return match ? { key: match[1], value: match[2] } : null; -} - -function parsePipeParts(line) { - const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim()); - const meta = {}; - parts.forEach((part) => { - const separatorIndex = part.indexOf("="); - if (separatorIndex < 0) return; - meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim(); - }); - return { title, meta }; -} - -function parseBoolean(value) { - return String(value || "").trim().toLowerCase() === "true"; -} - -function lineError(index, message) { - return new Error(`Ligne ${index + 1}: ${message}`); -} - -function parseNumber(value, fallback = 0) { - const parsed = Number(String(value || "").replace(",", ".")); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function parseInteger(value, fallback = 0) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function formatMeta(meta) { - return Object.entries(meta) - .filter(([, value]) => value !== "" && value !== undefined && value !== false) - .map(([key, value]) => `${key}=${value}`) - .join(" | "); -} - -function isValidImageDataUrl(value) { - return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim()); -} - -function cloneDrawingData(drawings) { - return { - strokes: (drawings?.strokes || []).map((stroke) => ({ - ...stroke, - points: (stroke.points || []).map((point) => ({ ...point })) - })) - }; -} - -function escapeHtml(value) { - return String(value || "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -function textToNotepadExchangeHtml(value) { - return String(value || "") - .split(/\r?\n/) - .map((line) => line ? `

${escapeHtml(line)}

` : "


") - .join(""); -} - -function exportNotepadText(data) { - const html = String(data?.html || "").trim() || textToNotepadExchangeHtml(data?.text || ""); - const lines = ["@notepad", "@html", html, "@endhtml"]; - if (data?.drawingMode === "permanent") lines.push("@drawingMode: permanent"); - if (data?.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`); - return lines.join("\n"); -} - -function cloneImportedNotepadDrawings(drawings, context) { - if (!drawings || typeof drawings !== "object" || !Array.isArray(drawings.strokes)) throw new Error("dessins invalides."); - return { - strokes: drawings.strokes.map((stroke) => { - if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length < 2) throw new Error("trait de dessin invalide."); - return { - ...stroke, - id: context.uid("stroke"), - points: stroke.points.map((point) => { - const x = Number(point?.x); - const y = Number(point?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("point de dessin invalide."); - return { x, y }; - }) - }; - }) - }; -} - -function importNotepadText(text, context) { - const lines = splitLines(text); - const hasStructuredMarker = lines.some((line) => ["@notepad", "@html", "@endhtml"].includes(line.trim()) || parseMetaLine(line)); - if (!hasStructuredMarker) return context.normalizeNotepadData({ text }); - - let readingHtml = false; - let htmlWasOpened = false; - let htmlWasClosed = false; - const htmlLines = []; - let drawingMode = "temporary"; - let drawings = { strokes: [] }; - lines.forEach((line, index) => { - const cleanLine = line.trim(); - if (readingHtml) { - if (cleanLine === "@endhtml") { - readingHtml = false; - htmlWasClosed = true; - return; - } - htmlLines.push(line); - return; - } - if (!cleanLine || cleanLine === "@notepad") return; - if (cleanLine === "@html") { - if (htmlWasOpened) throw lineError(index, "bloc html dupliqué."); - readingHtml = true; - htmlWasOpened = true; - return; - } - const meta = parseMetaLine(cleanLine); - if (meta?.key === "drawingMode") { - if (!["temporary", "permanent"].includes(meta.value)) throw lineError(index, "mode de dessin invalide."); - drawingMode = meta.value; - return; - } - if (meta?.key === "drawings") { - try { - drawings = cloneImportedNotepadDrawings(JSON.parse(meta.value), context); - } catch (error) { - throw lineError(index, error?.message || "dessins invalides."); - } - if (drawings.strokes.length) drawingMode = "permanent"; - return; - } - throw lineError(index, `ligne bloc notes invalide: ${cleanLine}`); - }); - if (readingHtml || (htmlWasOpened && !htmlWasClosed)) throw new Error("Bloc HTML non terminé."); - return context.normalizeNotepadData({ html: htmlLines.join("\n").trim(), drawingMode, drawings }); -} - -function exportChecklistText(data) { - return (data.sections || []).flatMap((section) => [ - section.title ? `# ${section.title}` : "", - ...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`) - ]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim(); -} - -function importChecklistText(text, context) { - const sections = []; - let current = { id: context.uid("section"), title: "", items: [] }; - splitLines(text).forEach((line) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - if (cleanLine.startsWith("#")) { - if (current.items.length) sections.push(current); - current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] }; - return; - } - parseColonImportLines(cleanLine).forEach((entry) => { - current.items.push({ - id: context.uid("item"), - label: entry.label, - qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)), - qtyCurrent: 0 - }); - }); - }); - if (current.items.length) sections.push(current); - return context.normalizeChecklistData({ sections }); -} - -function exportLinksText(data) { - return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n"); -} - -function importLinksText(text, context) { - const links = []; - splitLines(text).forEach((rawLine, index) => { - const line = rawLine.trim(); - if (!line) return; - const separatorIndex = line.indexOf(":"); - const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line; - const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : ""; - const url = context.normalizeUrl(title ? maybeUrl : line); - if (!url) throw lineError(index, `URL invalide: ${line}`); - links.push({ id: context.uid("link"), title, url }); - }); - return context.normalizeLinksData({ links }); -} - -function exportCountersText(data) { - return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n"); -} - -function importCountersText(text, context) { - const counters = []; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - const separatorIndex = cleanLine.indexOf(":"); - const entry = separatorIndex === -1 - ? { label: cleanLine, value: "" } - : { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() }; - if (!entry.label) return; - if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`); - const value = Number.parseInt(entry.value, 10); - if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`); - counters.push({ id: context.uid("counter"), label: entry.label, value }); - }); - return context.normalizeCountersData({ counters }); -} - -function exportCalculatorText(data) { - const lines = []; - if (data.scrollResults) lines.push("@scrollResults: true"); - function visit(parentId = "", depth = 0) { - (data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => { - const formula = String(entry.formula || "").trim(); - lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`); - visit(entry.id, depth + 1); - }); - } - visit(); - return lines.join("\n"); -} - -function importCalculatorText(text, context) { - const entries = []; - const parentStack = []; - let scrollResults = false; - splitLines(text).forEach((line, index) => { - if (!line.trim()) return; - const meta = parseMetaLine(line); - if (meta?.key === "scrollResults") { - scrollResults = parseBoolean(meta.value); - return; - } - const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); - const separatorIndex = line.indexOf(":"); - if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`); - const label = line.slice(0, separatorIndex).trim(); - const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/); - const value = parseNumber(rawValue, Number.NaN); - if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`); - const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() }; - entries.push(entry); - parentStack[depth] = entry.id; - parentStack.length = depth + 1; - }); - return context.normalizeCalculatorData({ entries, scrollResults }); -} - -function exportTableTsv(data) { - const lines = []; - if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`); - if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`); - if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`); - if (lines.length) lines.push("@tsv"); - for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) { - lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t")); - } - return lines.join("\n").replace(/\n+$/, ""); -} - -function importTableTsv(text) { - const lines = splitLines(text); - let rows = 0; - let columns = 0; - let rowLabels = {}; - let columnLabels = {}; - let dataLines = lines; - const metaLines = []; - const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv"); - if (tsvIndex >= 0) { - metaLines.push(...lines.slice(0, tsvIndex)); - dataLines = lines.slice(tsvIndex + 1); - } - metaLines.forEach((line, index) => { - const meta = parseMetaLine(line); - if (!meta) throw lineError(index, `meta tableau invalide: ${line}`); - if (meta.key === "size") { - const match = meta.value.match(/^(\d+)x(\d+)$/); - if (!match) throw lineError(index, "taille de tableau invalide."); - rows = parseInteger(match[1], 0); - columns = parseInteger(match[2], 0); - } - if (meta.key === "columns") { - columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index))); - } - if (meta.key === "rows") { - rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1))); - } - }); - const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t")); - if (!tableRows.length) throw new Error("TSV vide."); - rows = rows || tableRows.length; - columns = columns || Math.max(...tableRows.map((row) => row.length)); - const cells = {}; - for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) { - for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) { - const value = tableRows[rowIndex]?.[columnIndex] || ""; - if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value; - } - } - return { rows, columns, cells, rowLabels, columnLabels }; -} - -function exportComboInput(input) { - let suffix = ""; - if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`; - else if (input.hold) suffix = "[hold]"; - return `${input.kind}:${input.value}${suffix}`; -} - -function parseComboInput(text) { - const match = String(text || "").trim().match(/^([a-zA-Z]+):([^[]+?)(?:\[(hold|\d+s)\])?$/); - if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`); - const input = { kind: match[1], value: match[2].trim() }; - if (!input.value) throw new Error(`Input combo invalide: ${text}`); - if (match[3] === "hold") input.hold = true; - if (match[3]?.endsWith("s")) { - input.hold = true; - input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000)); - } - return input; -} - -function exportCombosText(data) { - const lines = []; - if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`); - if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`); - let lastCategory = null; - (data.combos || []).forEach((combo) => { - const category = combo.category || ""; - if (category && category !== lastCategory) { - lines.push(`# ${category}`); - lastCategory = category; - } - const meta = formatMeta({ device: combo.device || "" }); - const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > "); - lines.push([combo.name, meta, sequence].filter(Boolean).join(" | ")); - }); - return lines.join("\n"); -} - -function importCombosText(text, context) { - const combos = []; - let device = "playstation"; - let collapsedCategories = []; - let category = ""; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - const meta = parseMetaLine(cleanLine); - if (meta?.key === "device") { - if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide."); - device = meta.value; - return; - } - if (meta?.key === "collapsed") { - collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean); - return; - } - if (cleanLine.startsWith("#")) { - category = cleanLine.replace(/^#+/, "").trim(); - return; - } - const { title, meta: lineMeta } = parsePipeParts(cleanLine); - const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | "); - const comboDevice = lineMeta.device || device; - if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`); - let inputs; - try { - inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : []; - } catch (error) { - throw lineError(index, error?.message || "input combo invalide."); - } - combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs }); - }); - return context.normalizeCombosData({ device, collapsedCategories, combos }); -} - -function exportTaskPlannerText(data) { - const lines = []; - if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`); - if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`); - if (data.hideCompleted) lines.push("@hideCompleted: true"); - if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`); - const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation])); - const childrenByParent = new Map(); - (data.tasks || []).forEach((task) => { - const parentId = parentByChild.get(task.id)?.toTaskId || ""; - childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]); - }); - function writeTask(task, depth = 0) { - const prefix = " ".repeat(depth); - lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`); - if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`); - if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`); - if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`); - if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`); - (childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1)); - } - let lastCategory = null; - (childrenByParent.get("") || []).forEach((task) => { - const category = task.category || ""; - if (category && category !== lastCategory) { - lines.push(`# ${category}`); - lastCategory = category; - } - writeTask(task); - }); - return lines.join("\n"); -} - -function importTaskPlannerText(text, context) { - const tasks = []; - const relations = []; - const stack = []; - let category = ""; - const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations }; - splitLines(text).forEach((line, index) => { - if (!line.trim()) return; - const cleanLine = line.trim(); - const rootMeta = parseMetaLine(cleanLine); - if (rootMeta && !stack.length) { - if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1); - if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value; - if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value); - if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean); - return; - } - if (cleanLine.startsWith("#")) { - category = cleanLine.replace(/^#+/, "").trim(); - return; - } - const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); - const meta = parseMetaLine(cleanLine); - if (meta) { - const task = stack[Math.max(0, depth - 1)]; - if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`); - if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n"); - if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value; - if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1); - if (meta.key === "prerequisite") { - const relation = relations.find((item) => item.fromTaskId === task.id); - if (relation) relation.prerequisite = parseBoolean(meta.value); - } - return; - } - const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/); - if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`); - const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 }; - const parent = stack[depth - 1]; - if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false }); - else if (category) task.category = category; - tasks.push(task); - stack[depth] = task; - stack.length = depth + 1; - }); - return context.normalizeTaskPlannerData(data); -} - -function exportGoalTreeText(data) { - const lines = []; - if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`); - function visit(parentId = "", depth = 0) { - (data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => { - const prefix = " ".repeat(depth); - const formula = String(node.formula || "").trim(); - const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget; - const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : ""; - const meta = formatMeta({ - kind: node.kind !== "step" ? node.kind : "", - icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "", - color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "", - items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "", - current: node.qtyCurrent || "", - reset: node.resetType !== "none" ? node.resetType : "", - resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "", - weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "", - collapsed: node.collapsed - }); - lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`); - visit(node.id, depth + 1); - }); - } - visit(); - return lines.join("\n"); -} - -function importGoalTreeText(text, context) { - const nodes = []; - const stack = []; - const data = { lastResetAt: 0, nodes }; - splitLines(text).forEach((line, index) => { - if (!line.trim()) return; - const cleanLine = line.trim(); - const rootMeta = parseMetaLine(cleanLine); - if (rootMeta && !stack.length) { - if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0); - return; - } - const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); - const match = cleanLine.match(/^-\s+(.+)$/); - if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`); - const { title: rawTitle, meta } = parsePipeParts(match[1]); - const formulaIndex = rawTitle.lastIndexOf("="); - const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i); - const hasFormula = formulaIndex >= 0; - const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim(); - if (!title) throw lineError(index, "titre manquant."); - const parent = stack[depth - 1]; - const node = { - id: context.uid("goal"), - parentId: parent?.id || "", - title, - kind: meta.kind || "step", - icon: meta.icon || "cube", - color: meta.color || "gold", - qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1, - qtyCurrent: 0, - formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "", - items: parseGoalTreeItemsMeta(meta.items), - resetType: meta.reset || "none", - resetTime: meta.resetTime || "00:00", - weeklyResetDay: parseInteger(meta.weeklyResetDay, 1), - checkedAt: 0, - collapsed: parseBoolean(meta.collapsed) - }; - nodes.push(node); - stack[depth] = node; - stack.length = depth + 1; - }); - return context.normalizeGoalTreeData(data); -} - -function parseGoalTreeItemsMeta(value) { - if (!value) return []; - try { - const parsed = JSON.parse(decodeURIComponent(value)); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function exportTimerText(data) { - const lines = []; - if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`); - if (data.scrollResults) lines.push("@scrollResults: true"); - if (data.sortResults) lines.push("@sortResults: true"); - if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`); - (data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`)); - (data.countdowns || []).forEach((countdown) => { - const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true }; - if (countdown.type === "duration") meta.durationMs = countdown.durationMs; - if (countdown.type === "daily_time") meta.time = countdown.time; - if (countdown.type === "time_pattern") meta.pattern = countdown.pattern; - if (countdown.type === "interval") { - meta.intervalMs = countdown.intervalMs; - meta.startMode = countdown.startMode || "now"; - meta.startTime = countdown.startTime || ""; - } - lines.push(`countdown | ${formatMeta(meta)}`); - }); - return lines.join("\n"); -} - -function importTimerText(text, context) { - const now = Date.now(); - const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] }; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - const meta = parseMetaLine(cleanLine); - if (meta) { - if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch"; - if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value); - if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value); - if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0)); - return; - } - const { title, meta: lineMeta } = parsePipeParts(cleanLine); - if (title === "lap") { - data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) }); - return; - } - if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`); - const type = lineMeta.type; - if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`); - const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" }; - if (type === "duration") { - countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0)); - countdown.targetAt = now + countdown.durationMs; - } - if (type === "daily_time") { - countdown.time = lineMeta.time; - countdown.targetAt = getDailyTargetMs(countdown.time, now); - countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); - } - if (type === "time_pattern") { - countdown.pattern = lineMeta.pattern; - countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); - } - if (type === "interval") { - countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0)); - countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now"; - if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime; - countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now); - countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now); - countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); - } - data.countdowns.push(countdown); - }); - return context.normalizeTimerData(data); -} - -function parseCalendarDayIndex(value) { - const normalized = String(value || "").trim().toLowerCase(); - const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized); - if (weekdayIndex >= 0) return weekdayIndex; - const parsed = parseInteger(normalized, -1); - return parsed >= 0 && parsed <= 6 ? parsed : -1; -} - -function parseCalendarAlertOffsets(value) { - return String(value || "") - .split(",") - .map((item) => parseInteger(item.trim(), Number.NaN)) - .filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index) - .sort((a, b) => b - a); -} - -function formatCalendarEventMeta(event, viewMode) { - const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off"; - const meta = { - title: event.title, - color: event.color && event.color !== "violet" ? event.color : "", - repeatable: event.repeatable === false ? "false" : "", - alertMode: alertMode !== "off" ? alertMode : "", - alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : "" - }; - if (viewMode === "month") { - const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay"; - if (monthlyRepeatBy === "weekday") { - return { - ...meta, - repeatBy: "weekday", - repeat: event.repeat || "none", - startDate: event.startDate, - endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : "" - }; - } - return { - ...meta, - repeatBy: "monthDay", - repeat: event.repeat || "none", - startDate: event.startDate, - endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : "" - }; - } - return { - ...meta, - day: CALENDAR_WEEKDAYS[event.dayIndex] || "", - start: minutesToTimeValue(event.startMinutes), - end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "" - }; -} - -function exportCalendarText(data) { - const viewMode = data?.viewMode === "month" ? "month" : "week"; - const lines = [`@viewMode: ${viewMode}`]; - if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0"); - (data?.events || []).forEach((event) => { - lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`); - }); - return lines.join("\n"); -} - -function importCalendarText(text, context) { - const trimmed = String(text || "").trim(); - if (trimmed.startsWith("{")) { - return context.normalizeCalendarData(JSON.parse(trimmed)); - } - - const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] }; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - const meta = parseMetaLine(cleanLine); - if (meta) { - if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week"; - if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1; - return; - } - - const { title, meta: lineMeta } = parsePipeParts(cleanLine); - if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`); - const event = { - id: context.uid("calendar"), - title: lineMeta.title || "", - repeatable: lineMeta.repeatable !== "false", - alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off", - alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts), - color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet" - }; - if (data.viewMode === "month") { - const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay"; - event.startMinutes = 0; - event.monthlyRepeatBy = monthlyRepeatBy; - event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none"; - event.startDate = lineMeta.startDate || ""; - event.endDate = lineMeta.endDate || event.startDate; - } else { - event.dayIndex = parseCalendarDayIndex(lineMeta.day); - event.startMinutes = timeValueToMinutes(lineMeta.start); - if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end); - } - data.events.push(event); - }); - return context.normalizeCalendarData(data); -} - -function exportImagesText(data) { - return (data.images || []).flatMap((image) => [ - image.label ? `# ${image.label}` : "#", - image.dataUrl - ]).join("\n"); -} - -function importImagesText(text, context) { - const images = []; - let label = ""; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - if (cleanLine.startsWith("#")) { - label = cleanLine.replace(/^#+/, "").trim(); - return; - } - if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide."); - images.push({ id: context.uid("image"), label, dataUrl: cleanLine }); - label = ""; - }); - return { images }; -} - -function exportImageAnnotationText(data) { - const lines = [data.image]; - (data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`)); - if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`); - return lines.join("\n"); -} - -function importImageAnnotationText(text, context) { - const markers = []; - let image = ""; - let drawings = { strokes: [] }; - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - const meta = parseMetaLine(cleanLine); - if (meta?.key === "marker") { - const [x, y, ...labelParts] = meta.value.split(","); - const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() }; - if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide."); - markers.push(marker); - return; - } - if (meta?.key === "drawings") { - try { - drawings = JSON.parse(meta.value); - } catch { - throw lineError(index, "dessins invalides."); - } - return; - } - if (image) throw lineError(index, "une seule image peut être importée."); - if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide."); - image = cleanLine; - }); - return context.normalizeImageAnnotationData({ image, markers, drawings }); -} - -function exportEquipmentPlannerText(data) { - const lines = []; - const typesById = new Map((data.types || []).map((type) => [type.id, type])); - (data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => { - const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId)); - if (!equipments.length) return; - lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`); - equipments.forEach((equipment) => { - lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`); - if (equipment.characteristics?.length) { - lines.push("Stats"); - equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`)); - } - if (equipment.socketItems?.length) { - lines.push("Sockets"); - equipment.socketItems.forEach((socketItem) => { - lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`); - (socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`)); - }); - if (equipment.socketLinks?.length) { - const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name])); - equipment.socketLinks.forEach((link) => { - const fromName = socketItemsById.get(link.fromSocketItemId); - const toName = socketItemsById.get(link.toSocketItemId); - if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`); - }); - } - } - if (equipment.obtain || equipment.materials?.length) { - lines.push("Craft"); - if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`); - (equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`)); - } - }); - }); - return lines.join("\n"); -} - -function importEquipmentPlannerText(text, context) { - const types = []; - const equipments = []; - let currentType = null; - let currentEquipment = null; - let section = ""; - let currentSocket = null; - const socketsByName = new Map(); - splitLines(text).forEach((line, index) => { - const cleanLine = line.trim(); - if (!cleanLine) return; - if (cleanLine.startsWith("##")) { - if (!currentType) throw lineError(index, "équipement sans type."); - const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim()); - currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] }; - equipments.push(currentEquipment); - section = ""; - currentSocket = null; - socketsByName.clear(); - return; - } - if (cleanLine.startsWith("#")) { - const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim()); - currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) }; - types.push(currentType); - currentEquipment = null; - section = ""; - return; - } - if (["Stats", "Sockets", "Craft"].includes(cleanLine)) { - if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`); - section = cleanLine; - currentSocket = null; - return; - } - if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`); - if (section === "Stats") { - const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim()); - currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value }); - } - if (section === "Sockets") { - if (cleanLine.startsWith("bonus")) { - if (!currentSocket) throw lineError(index, "bonus sans objet serti."); - const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim()); - currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value }); - } else if (cleanLine.startsWith("link")) { - const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim()); - const fromSocketItemId = socketsByName.get(fromName); - const toSocketItemId = socketsByName.get(toName); - if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide."); - currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId }); - } else { - const { title, meta } = parsePipeParts(cleanLine); - currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] }; - currentEquipment.socketItems.push(currentSocket); - socketsByName.set(title, currentSocket.id); - } - } - if (section === "Craft") { - const meta = parseMetaLine(cleanLine); - if (meta?.key === "obtain") { - currentEquipment.obtain = meta.value.replace(/\\n/g, "\n"); - } else { - const [entry] = parseColonImportLines(cleanLine); - if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`); - currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) }); - } - } - }); - return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) }); -} diff --git a/website/src/features/toolboxes/modules/textImport/basic.js b/website/src/features/toolboxes/modules/textImport/basic.js new file mode 100644 index 0000000..d9871e2 --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/basic.js @@ -0,0 +1,259 @@ +// Rôle : gère l'import/export texte des outils simples et tabulaires. +import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js"; +import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, splitLines, textToNotepadExchangeHtml } from "./shared.js"; + +export function exportNotepadText(data) { + const html = String(data?.html || "").trim() || textToNotepadExchangeHtml(data?.text || ""); + const lines = ["@notepad", "@html", html, "@endhtml"]; + if (data?.drawingMode === "permanent") lines.push("@drawingMode: permanent"); + if (data?.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`); + return lines.join("\n"); +} + + +export function cloneImportedNotepadDrawings(drawings, context) { + if (!drawings || typeof drawings !== "object" || !Array.isArray(drawings.strokes)) throw new Error("dessins invalides."); + return { + strokes: drawings.strokes.map((stroke) => { + if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length < 2) throw new Error("trait de dessin invalide."); + return { + ...stroke, + id: context.uid("stroke"), + points: stroke.points.map((point) => { + const x = Number(point?.x); + const y = Number(point?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("point de dessin invalide."); + return { x, y }; + }) + }; + }) + }; +} + + +export function importNotepadText(text, context) { + const lines = splitLines(text); + const hasStructuredMarker = lines.some((line) => ["@notepad", "@html", "@endhtml"].includes(line.trim()) || parseMetaLine(line)); + if (!hasStructuredMarker) return context.normalizeNotepadData({ text }); + + let readingHtml = false; + let htmlWasOpened = false; + let htmlWasClosed = false; + const htmlLines = []; + let drawingMode = "temporary"; + let drawings = { strokes: [] }; + lines.forEach((line, index) => { + const cleanLine = line.trim(); + if (readingHtml) { + if (cleanLine === "@endhtml") { + readingHtml = false; + htmlWasClosed = true; + return; + } + htmlLines.push(line); + return; + } + if (!cleanLine || cleanLine === "@notepad") return; + if (cleanLine === "@html") { + if (htmlWasOpened) throw lineError(index, "bloc html dupliqué."); + readingHtml = true; + htmlWasOpened = true; + return; + } + const meta = parseMetaLine(cleanLine); + if (meta?.key === "drawingMode") { + if (!["temporary", "permanent"].includes(meta.value)) throw lineError(index, "mode de dessin invalide."); + drawingMode = meta.value; + return; + } + if (meta?.key === "drawings") { + try { + drawings = cloneImportedNotepadDrawings(JSON.parse(meta.value), context); + } catch (error) { + throw lineError(index, error?.message || "dessins invalides."); + } + if (drawings.strokes.length) drawingMode = "permanent"; + return; + } + throw lineError(index, `ligne bloc notes invalide: ${cleanLine}`); + }); + if (readingHtml || (htmlWasOpened && !htmlWasClosed)) throw new Error("Bloc HTML non terminé."); + return context.normalizeNotepadData({ html: htmlLines.join("\n").trim(), drawingMode, drawings }); +} + + +export function exportChecklistText(data) { + return (data.sections || []).flatMap((section) => [ + section.title ? `# ${section.title}` : "", + ...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`) + ]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim(); +} + + +export function importChecklistText(text, context) { + const sections = []; + let current = { id: context.uid("section"), title: "", items: [] }; + splitLines(text).forEach((line) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + if (cleanLine.startsWith("#")) { + if (current.items.length) sections.push(current); + current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] }; + return; + } + parseColonImportLines(cleanLine).forEach((entry) => { + current.items.push({ + id: context.uid("item"), + label: entry.label, + qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)), + qtyCurrent: 0 + }); + }); + }); + if (current.items.length) sections.push(current); + return context.normalizeChecklistData({ sections }); +} + + +export function exportLinksText(data) { + return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n"); +} + + +export function importLinksText(text, context) { + const links = []; + splitLines(text).forEach((rawLine, index) => { + const line = rawLine.trim(); + if (!line) return; + const separatorIndex = line.indexOf(":"); + const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line; + const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : ""; + const url = context.normalizeUrl(title ? maybeUrl : line); + if (!url) throw lineError(index, `URL invalide: ${line}`); + links.push({ id: context.uid("link"), title, url }); + }); + return context.normalizeLinksData({ links }); +} + + +export function exportCountersText(data) { + return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n"); +} + + +export function importCountersText(text, context) { + const counters = []; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + const separatorIndex = cleanLine.indexOf(":"); + const entry = separatorIndex === -1 + ? { label: cleanLine, value: "" } + : { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() }; + if (!entry.label) return; + if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`); + const value = Number.parseInt(entry.value, 10); + if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`); + counters.push({ id: context.uid("counter"), label: entry.label, value }); + }); + return context.normalizeCountersData({ counters }); +} + + +export function exportCalculatorText(data) { + const lines = []; + if (data.scrollResults) lines.push("@scrollResults: true"); + function visit(parentId = "", depth = 0) { + (data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => { + const formula = String(entry.formula || "").trim(); + lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`); + visit(entry.id, depth + 1); + }); + } + visit(); + return lines.join("\n"); +} + + +export function importCalculatorText(text, context) { + const entries = []; + const parentStack = []; + let scrollResults = false; + splitLines(text).forEach((line, index) => { + if (!line.trim()) return; + const meta = parseMetaLine(line); + if (meta?.key === "scrollResults") { + scrollResults = parseBoolean(meta.value); + return; + } + const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); + const separatorIndex = line.indexOf(":"); + if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`); + const label = line.slice(0, separatorIndex).trim(); + const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/); + const value = parseNumber(rawValue, Number.NaN); + if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`); + const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() }; + entries.push(entry); + parentStack[depth] = entry.id; + parentStack.length = depth + 1; + }); + return context.normalizeCalculatorData({ entries, scrollResults }); +} + + +export function exportTableTsv(data) { + const lines = []; + if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`); + if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`); + if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`); + if (lines.length) lines.push("@tsv"); + for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) { + lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t")); + } + return lines.join("\n").replace(/\n+$/, ""); +} + + +export function importTableTsv(text) { + const lines = splitLines(text); + let rows = 0; + let columns = 0; + let rowLabels = {}; + let columnLabels = {}; + let dataLines = lines; + const metaLines = []; + const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv"); + if (tsvIndex >= 0) { + metaLines.push(...lines.slice(0, tsvIndex)); + dataLines = lines.slice(tsvIndex + 1); + } + metaLines.forEach((line, index) => { + const meta = parseMetaLine(line); + if (!meta) throw lineError(index, `meta tableau invalide: ${line}`); + if (meta.key === "size") { + const match = meta.value.match(/^(\d+)x(\d+)$/); + if (!match) throw lineError(index, "taille de tableau invalide."); + rows = parseInteger(match[1], 0); + columns = parseInteger(match[2], 0); + } + if (meta.key === "columns") { + columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index))); + } + if (meta.key === "rows") { + rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1))); + } + }); + const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t")); + if (!tableRows.length) throw new Error("TSV vide."); + rows = rows || tableRows.length; + columns = columns || Math.max(...tableRows.map((row) => row.length)); + const cells = {}; + for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) { + for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) { + const value = tableRows[rowIndex]?.[columnIndex] || ""; + if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value; + } + } + return { rows, columns, cells, rowLabels, columnLabels }; +} diff --git a/website/src/features/toolboxes/modules/textImport/combos.js b/website/src/features/toolboxes/modules/textImport/combos.js new file mode 100644 index 0000000..2ac8d04 --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/combos.js @@ -0,0 +1,84 @@ +// Rôle : gère l'import/export texte des combos de commandes. +import { formatMeta, lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js"; + +const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); +const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); + +export function exportComboInput(input) { + let suffix = ""; + if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`; + else if (input.hold) suffix = "[hold]"; + return `${input.kind}:${input.value}${suffix}`; +} + + +export function parseComboInput(text) { + const match = String(text || "").trim().match(/^([a-zA-Z]+):([^[]+?)(?:\[(hold|\d+s)\])?$/); + if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`); + const input = { kind: match[1], value: match[2].trim() }; + if (!input.value) throw new Error(`Input combo invalide: ${text}`); + if (match[3] === "hold") input.hold = true; + if (match[3]?.endsWith("s")) { + input.hold = true; + input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000)); + } + return input; +} + + +export function exportCombosText(data) { + const lines = []; + if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`); + if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`); + let lastCategory = null; + (data.combos || []).forEach((combo) => { + const category = combo.category || ""; + if (category && category !== lastCategory) { + lines.push(`# ${category}`); + lastCategory = category; + } + const meta = formatMeta({ device: combo.device || "" }); + const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > "); + lines.push([combo.name, meta, sequence].filter(Boolean).join(" | ")); + }); + return lines.join("\n"); +} + + +export function importCombosText(text, context) { + const combos = []; + let device = "playstation"; + let collapsedCategories = []; + let category = ""; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + const meta = parseMetaLine(cleanLine); + if (meta?.key === "device") { + if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide."); + device = meta.value; + return; + } + if (meta?.key === "collapsed") { + collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean); + return; + } + if (cleanLine.startsWith("#")) { + category = cleanLine.replace(/^#+/, "").trim(); + return; + } + const { title, meta: lineMeta } = parsePipeParts(cleanLine); + const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | "); + const comboDevice = lineMeta.device || device; + if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`); + let inputs; + try { + inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : []; + } catch (error) { + throw lineError(index, error?.message || "input combo invalide."); + } + combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs }); + }); + return context.normalizeCombosData({ device, collapsedCategories, combos }); +} + diff --git a/website/src/features/toolboxes/modules/textImport/equipment.js b/website/src/features/toolboxes/modules/textImport/equipment.js new file mode 100644 index 0000000..b759f63 --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/equipment.js @@ -0,0 +1,114 @@ +// Rôle : gère l'import/export texte du planificateur d'équipements. +import { formatMeta, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js"; + +export function exportEquipmentPlannerText(data) { + const lines = []; + const typesById = new Map((data.types || []).map((type) => [type.id, type])); + (data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => { + const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId)); + if (!equipments.length) return; + lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`); + equipments.forEach((equipment) => { + lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`); + if (equipment.characteristics?.length) { + lines.push("Stats"); + equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`)); + } + if (equipment.socketItems?.length) { + lines.push("Sockets"); + equipment.socketItems.forEach((socketItem) => { + lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`); + (socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`)); + }); + if (equipment.socketLinks?.length) { + const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name])); + equipment.socketLinks.forEach((link) => { + const fromName = socketItemsById.get(link.fromSocketItemId); + const toName = socketItemsById.get(link.toSocketItemId); + if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`); + }); + } + } + if (equipment.obtain || equipment.materials?.length) { + lines.push("Craft"); + if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`); + (equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`)); + } + }); + }); + return lines.join("\n"); +} + + +export function importEquipmentPlannerText(text, context) { + const types = []; + const equipments = []; + let currentType = null; + let currentEquipment = null; + let section = ""; + let currentSocket = null; + const socketsByName = new Map(); + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + if (cleanLine.startsWith("##")) { + if (!currentType) throw lineError(index, "équipement sans type."); + const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim()); + currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] }; + equipments.push(currentEquipment); + section = ""; + currentSocket = null; + socketsByName.clear(); + return; + } + if (cleanLine.startsWith("#")) { + const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim()); + currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) }; + types.push(currentType); + currentEquipment = null; + section = ""; + return; + } + if (["Stats", "Sockets", "Craft"].includes(cleanLine)) { + if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`); + section = cleanLine; + currentSocket = null; + return; + } + if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`); + if (section === "Stats") { + const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim()); + currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value }); + } + if (section === "Sockets") { + if (cleanLine.startsWith("bonus")) { + if (!currentSocket) throw lineError(index, "bonus sans objet serti."); + const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim()); + currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value }); + } else if (cleanLine.startsWith("link")) { + const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim()); + const fromSocketItemId = socketsByName.get(fromName); + const toSocketItemId = socketsByName.get(toName); + if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide."); + currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId }); + } else { + const { title, meta } = parsePipeParts(cleanLine); + currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] }; + currentEquipment.socketItems.push(currentSocket); + socketsByName.set(title, currentSocket.id); + } + } + if (section === "Craft") { + const meta = parseMetaLine(cleanLine); + if (meta?.key === "obtain") { + currentEquipment.obtain = meta.value.replace(/\\n/g, "\n"); + } else { + const [entry] = parseColonImportLines(cleanLine); + if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`); + currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) }); + } + } + }); + return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) }); +} + diff --git a/website/src/features/toolboxes/modules/textImport/media.js b/website/src/features/toolboxes/modules/textImport/media.js new file mode 100644 index 0000000..ef1dff6 --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/media.js @@ -0,0 +1,67 @@ +// Rôle : gère l'import/export texte des outils images et annotations. +import { cloneDrawingData, isValidImageDataUrl, lineError, parseMetaLine, parseNumber, splitLines } from "./shared.js"; + +export function exportImagesText(data) { + return (data.images || []).flatMap((image) => [ + image.label ? `# ${image.label}` : "#", + image.dataUrl + ]).join("\n"); +} + + +export function importImagesText(text, context) { + const images = []; + let label = ""; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + if (cleanLine.startsWith("#")) { + label = cleanLine.replace(/^#+/, "").trim(); + return; + } + if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide."); + images.push({ id: context.uid("image"), label, dataUrl: cleanLine }); + label = ""; + }); + return { images }; +} + + +export function exportImageAnnotationText(data) { + const lines = [data.image]; + (data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`)); + if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`); + return lines.join("\n"); +} + + +export function importImageAnnotationText(text, context) { + const markers = []; + let image = ""; + let drawings = { strokes: [] }; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + const meta = parseMetaLine(cleanLine); + if (meta?.key === "marker") { + const [x, y, ...labelParts] = meta.value.split(","); + const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() }; + if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide."); + markers.push(marker); + return; + } + if (meta?.key === "drawings") { + try { + drawings = JSON.parse(meta.value); + } catch { + throw lineError(index, "dessins invalides."); + } + return; + } + if (image) throw lineError(index, "une seule image peut être importée."); + if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide."); + image = cleanLine; + }); + return context.normalizeImageAnnotationData({ image, markers, drawings }); +} + diff --git a/website/src/features/toolboxes/modules/textImport/planning.js b/website/src/features/toolboxes/modules/textImport/planning.js new file mode 100644 index 0000000..ac5724c --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/planning.js @@ -0,0 +1,172 @@ +// Rôle : gère l'import/export texte des plans de tâches et arbres d'objectifs. +import { formatMeta, lineError, parseBoolean, parseInteger, parseMetaLine, parseNumber, parsePipeParts, splitLines } from "./shared.js"; + +export function exportTaskPlannerText(data) { + const lines = []; + if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`); + if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`); + if (data.hideCompleted) lines.push("@hideCompleted: true"); + if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`); + const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation])); + const childrenByParent = new Map(); + (data.tasks || []).forEach((task) => { + const parentId = parentByChild.get(task.id)?.toTaskId || ""; + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]); + }); + function writeTask(task, depth = 0) { + const prefix = " ".repeat(depth); + lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`); + if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`); + if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`); + if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`); + if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`); + (childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1)); + } + let lastCategory = null; + (childrenByParent.get("") || []).forEach((task) => { + const category = task.category || ""; + if (category && category !== lastCategory) { + lines.push(`# ${category}`); + lastCategory = category; + } + writeTask(task); + }); + return lines.join("\n"); +} + + +export function importTaskPlannerText(text, context) { + const tasks = []; + const relations = []; + const stack = []; + let category = ""; + const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations }; + splitLines(text).forEach((line, index) => { + if (!line.trim()) return; + const cleanLine = line.trim(); + const rootMeta = parseMetaLine(cleanLine); + if (rootMeta && !stack.length) { + if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1); + if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value; + if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value); + if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean); + return; + } + if (cleanLine.startsWith("#")) { + category = cleanLine.replace(/^#+/, "").trim(); + return; + } + const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); + const meta = parseMetaLine(cleanLine); + if (meta) { + const task = stack[Math.max(0, depth - 1)]; + if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`); + if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n"); + if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value; + if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1); + if (meta.key === "prerequisite") { + const relation = relations.find((item) => item.fromTaskId === task.id); + if (relation) relation.prerequisite = parseBoolean(meta.value); + } + return; + } + const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/); + if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`); + const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 }; + const parent = stack[depth - 1]; + if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false }); + else if (category) task.category = category; + tasks.push(task); + stack[depth] = task; + stack.length = depth + 1; + }); + return context.normalizeTaskPlannerData(data); +} + + +export function exportGoalTreeText(data) { + const lines = []; + if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`); + function visit(parentId = "", depth = 0) { + (data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => { + const prefix = " ".repeat(depth); + const formula = String(node.formula || "").trim(); + const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget; + const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : ""; + const meta = formatMeta({ + kind: node.kind !== "step" ? node.kind : "", + icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "", + color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "", + items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "", + current: node.qtyCurrent || "", + reset: node.resetType !== "none" ? node.resetType : "", + resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "", + weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "", + collapsed: node.collapsed + }); + lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`); + visit(node.id, depth + 1); + }); + } + visit(); + return lines.join("\n"); +} + + +export function importGoalTreeText(text, context) { + const nodes = []; + const stack = []; + const data = { lastResetAt: 0, nodes }; + splitLines(text).forEach((line, index) => { + if (!line.trim()) return; + const cleanLine = line.trim(); + const rootMeta = parseMetaLine(cleanLine); + if (rootMeta && !stack.length) { + if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0); + return; + } + const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2); + const match = cleanLine.match(/^-\s+(.+)$/); + if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`); + const { title: rawTitle, meta } = parsePipeParts(match[1]); + const formulaIndex = rawTitle.lastIndexOf("="); + const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i); + const hasFormula = formulaIndex >= 0; + const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim(); + if (!title) throw lineError(index, "titre manquant."); + const parent = stack[depth - 1]; + const node = { + id: context.uid("goal"), + parentId: parent?.id || "", + title, + kind: meta.kind || "step", + icon: meta.icon || "cube", + color: meta.color || "gold", + qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1, + qtyCurrent: 0, + formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "", + items: parseGoalTreeItemsMeta(meta.items), + resetType: meta.reset || "none", + resetTime: meta.resetTime || "00:00", + weeklyResetDay: parseInteger(meta.weeklyResetDay, 1), + checkedAt: 0, + collapsed: parseBoolean(meta.collapsed) + }; + nodes.push(node); + stack[depth] = node; + stack.length = depth + 1; + }); + return context.normalizeGoalTreeData(data); +} + + +export function parseGoalTreeItemsMeta(value) { + if (!value) return []; + try { + const parsed = JSON.parse(decodeURIComponent(value)); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + diff --git a/website/src/features/toolboxes/modules/textImport/shared.js b/website/src/features/toolboxes/modules/textImport/shared.js new file mode 100644 index 0000000..7b647bb --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/shared.js @@ -0,0 +1,94 @@ +// Rôle : fournit les helpers communs aux formats texte des outils toolbox. +const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i; + +export function parseColonImportLines(text) { + return String(text || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) return { label: line, value: "" }; + return { + label: line.slice(0, separatorIndex).trim(), + value: line.slice(separatorIndex + 1).trim() + }; + }) + .filter((entry) => entry.label); +} + +export function fail(error) { + return { ok: false, error }; +} + +export function splitLines(text) { + return String(text || "").split(/\r?\n/); +} + +export function parseMetaLine(line) { + const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/); + return match ? { key: match[1], value: match[2] } : null; +} + +export function parsePipeParts(line) { + const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim()); + const meta = {}; + parts.forEach((part) => { + const separatorIndex = part.indexOf("="); + if (separatorIndex < 0) return; + meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim(); + }); + return { title, meta }; +} + +export function parseBoolean(value) { + return String(value || "").trim().toLowerCase() === "true"; +} + +export function lineError(index, message) { + return new Error(`Ligne ${index + 1}: ${message}`); +} + +export function parseNumber(value, fallback = 0) { + const parsed = Number(String(value || "").replace(",", ".")); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function parseInteger(value, fallback = 0) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function formatMeta(meta) { + return Object.entries(meta) + .filter(([, value]) => value !== "" && value !== undefined && value !== false) + .map(([key, value]) => `${key}=${value}`) + .join(" | "); +} + +export function isValidImageDataUrl(value) { + return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim()); +} + +export function cloneDrawingData(drawings) { + return { + strokes: (drawings?.strokes || []).map((stroke) => ({ + ...stroke, + points: (stroke.points || []).map((point) => ({ ...point })) + })) + }; +} + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +export function textToNotepadExchangeHtml(value) { + return String(value || "") + .split(/\r?\n/) + .map((line) => line ? `

${escapeHtml(line)}

` : "


") + .join(""); +} diff --git a/website/src/features/toolboxes/modules/textImport/time.js b/website/src/features/toolboxes/modules/textImport/time.js new file mode 100644 index 0000000..55d329f --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/time.js @@ -0,0 +1,197 @@ +// Rôle : gère l'import/export texte des outils temporels timer et calendrier. +import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "../calendarUtils.js"; +import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "../timerUtils.js"; +import { formatMeta, lineError, parseBoolean, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js"; + +const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]); +const ALERT_MODES = new Set(["off", "visible", "site"]); +const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES); +const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS); +const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS); +const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY); +const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES); +const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]; + +export function exportTimerText(data) { + const lines = []; + if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`); + if (data.scrollResults) lines.push("@scrollResults: true"); + if (data.sortResults) lines.push("@sortResults: true"); + if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`); + (data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`)); + (data.countdowns || []).forEach((countdown) => { + const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true }; + if (countdown.type === "duration") meta.durationMs = countdown.durationMs; + if (countdown.type === "daily_time") meta.time = countdown.time; + if (countdown.type === "time_pattern") meta.pattern = countdown.pattern; + if (countdown.type === "interval") { + meta.intervalMs = countdown.intervalMs; + meta.startMode = countdown.startMode || "now"; + meta.startTime = countdown.startTime || ""; + } + lines.push(`countdown | ${formatMeta(meta)}`); + }); + return lines.join("\n"); +} + + +export function importTimerText(text, context) { + const now = Date.now(); + const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] }; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + const meta = parseMetaLine(cleanLine); + if (meta) { + if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch"; + if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value); + if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value); + if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0)); + return; + } + const { title, meta: lineMeta } = parsePipeParts(cleanLine); + if (title === "lap") { + data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) }); + return; + } + if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`); + const type = lineMeta.type; + if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`); + const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" }; + if (type === "duration") { + countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0)); + countdown.targetAt = now + countdown.durationMs; + } + if (type === "daily_time") { + countdown.time = lineMeta.time; + countdown.targetAt = getDailyTargetMs(countdown.time, now); + countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); + } + if (type === "time_pattern") { + countdown.pattern = lineMeta.pattern; + countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); + } + if (type === "interval") { + countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0)); + countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now"; + if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime; + countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now); + countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now); + countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh); + } + data.countdowns.push(countdown); + }); + return context.normalizeTimerData(data); +} + + +export function parseCalendarDayIndex(value) { + const normalized = String(value || "").trim().toLowerCase(); + const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized); + if (weekdayIndex >= 0) return weekdayIndex; + const parsed = parseInteger(normalized, -1); + return parsed >= 0 && parsed <= 6 ? parsed : -1; +} + + +export function parseCalendarAlertOffsets(value) { + return String(value || "") + .split(",") + .map((item) => parseInteger(item.trim(), Number.NaN)) + .filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index) + .sort((a, b) => b - a); +} + + +export function formatCalendarEventMeta(event, viewMode) { + const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off"; + const meta = { + title: event.title, + color: event.color && event.color !== "violet" ? event.color : "", + repeatable: event.repeatable === false ? "false" : "", + alertMode: alertMode !== "off" ? alertMode : "", + alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : "" + }; + if (viewMode === "month") { + const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay"; + if (monthlyRepeatBy === "weekday") { + return { + ...meta, + repeatBy: "weekday", + repeat: event.repeat || "none", + startDate: event.startDate, + endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : "" + }; + } + return { + ...meta, + repeatBy: "monthDay", + repeat: event.repeat || "none", + startDate: event.startDate, + endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : "" + }; + } + return { + ...meta, + day: CALENDAR_WEEKDAYS[event.dayIndex] || "", + start: minutesToTimeValue(event.startMinutes), + end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "" + }; +} + + +export function exportCalendarText(data) { + const viewMode = data?.viewMode === "month" ? "month" : "week"; + const lines = [`@viewMode: ${viewMode}`]; + if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0"); + (data?.events || []).forEach((event) => { + lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`); + }); + return lines.join("\n"); +} + + +export function importCalendarText(text, context) { + const trimmed = String(text || "").trim(); + if (trimmed.startsWith("{")) { + return context.normalizeCalendarData(JSON.parse(trimmed)); + } + + const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] }; + splitLines(text).forEach((line, index) => { + const cleanLine = line.trim(); + if (!cleanLine) return; + const meta = parseMetaLine(cleanLine); + if (meta) { + if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week"; + if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1; + return; + } + + const { title, meta: lineMeta } = parsePipeParts(cleanLine); + if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`); + const event = { + id: context.uid("calendar"), + title: lineMeta.title || "", + repeatable: lineMeta.repeatable !== "false", + alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off", + alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts), + color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet" + }; + if (data.viewMode === "month") { + const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay"; + event.startMinutes = 0; + event.monthlyRepeatBy = monthlyRepeatBy; + event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none"; + event.startDate = lineMeta.startDate || ""; + event.endDate = lineMeta.endDate || event.startDate; + } else { + event.dayIndex = parseCalendarDayIndex(lineMeta.day); + event.startMinutes = timeValueToMinutes(lineMeta.start); + if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end); + } + data.events.push(event); + }); + return context.normalizeCalendarData(data); +} + diff --git a/website/src/styles/toolboxes/_goal-tree.scss b/website/src/styles/toolboxes/_goal-tree.scss index 3aa50f2..0cd6514 100644 --- a/website/src/styles/toolboxes/_goal-tree.scss +++ b/website/src/styles/toolboxes/_goal-tree.scss @@ -1,1194 +1,4 @@ -// Rôle : stylise l'outil arbre d'objectifs et ses branches compactes. -.goal-tree-module { - display: grid; - grid-template-columns: minmax(320px, 40fr) minmax(0, 60fr); - column-gap: var(--space-6); - row-gap: var(--space-4); - align-items: start; - overflow: hidden; - padding: var(--space-4); -} - -.module[data-module-type="goalTree"] { - contain: paint; - overflow: hidden; -} - -.module[data-module-type="goalTree"] > .module-content { - min-width: 0; -} - -.module[data-module-type="goalTree"] > .module-content.is-scrollable { - overflow: auto; -} - -.goal-tree-editor { - align-content: start; - gap: var(--space-3); -} - -.goal-tree-editor label, -.goal-tree-selected-form label { - display: grid; - gap: 5px; - min-width: 0; - color: var(--color-text-secondary); - font-size: 0.82rem; - font-weight: 700; -} - -.goal-tree-editor input, -.goal-tree-editor select { - min-width: 0; - width: 100%; -} - -.goal-tree-field-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.goal-tree-selected-form > button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; -} - -.goal-tree-selected-form { - display: grid; - gap: var(--space-3); - padding-top: var(--space-2); - border-top: 1px solid rgba(150, 165, 205, 0.14); -} - -.goal-tree-form-tabs { - width: 100%; - grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); -} - -.goal-tree-form-tabs button { - min-height: 34px; - gap: 6px; - padding: 0 8px; - font-size: 0.78rem; -} - -.goal-tree-form-tabs .module-icon-svg { - width: 15px; - height: 15px; - flex: 0 0 15px; -} - -.goal-tree-form-tabs .module-icon-settings { - width: 16px; - height: 16px; - flex-basis: 16px; - transform: scale(1.12); -} - -.goal-tree-form-tabs button > span:not(.module-icon-svg) { - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-form-panel { - display: grid; - gap: var(--space-3); -} - -.goal-tree-form-section-title, -.goal-tree-color-field > span, -.goal-tree-icon-field > span { - color: var(--color-text-secondary); - font-size: 0.78rem; - font-weight: 800; - text-transform: uppercase; -} - -.goal-tree-content-editor { - display: grid; - gap: 8px; -} - -.goal-tree-content-editor-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 30px; - gap: 6px; - align-items: center; -} - -.goal-tree-content-editor-row.is-checklist { - grid-template-columns: minmax(0, 1fr) 30px; - grid-template-areas: - "total delete" - "label delete"; -} - -.goal-tree-content-label-input { - grid-area: label; -} - -.goal-tree-content-formula-input, -.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap { - grid-area: total; -} - -.goal-tree-content-editor-row.is-checklist button { - grid-area: delete; - align-self: stretch; -} - -.goal-tree-content-editor-row > input[type="checkbox"] { - width: 18px; - height: 18px; -} - -.goal-tree-content-editor-row button { - min-width: 30px; - min-height: 30px; - padding: 0; -} - -.goal-tree-content-qty-stepper { - display: grid; - grid-template-columns: 30px minmax(42px, auto) 30px; - gap: 4px; - align-items: center; - color: var(--color-text-secondary); - font-size: 0.78rem; - font-weight: 800; - white-space: nowrap; -} - -.goal-tree-content-qty-stepper > span { - text-align: center; -} - -.goal-tree-content-hint { - margin: 0; - color: var(--color-text-muted); - font-size: 0.82rem; -} - -.goal-tree-content-add-button { - display: inline-flex; - justify-content: center; - gap: 7px; - border-style: dashed; -} - -.tool-split-scroll-toggle.goal-tree-progress-mode-toggle { - grid-template-columns: 18px minmax(0, 1fr) 34px; - width: 100%; - justify-items: start; -} - -.tool-split-scroll-toggle.goal-tree-progress-mode-toggle span:not(.ui-icon) { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-progress-mode-hint { - margin: -2px 0 0; - color: var(--color-text-muted); - font-size: 0.76rem; - line-height: 1.35; -} - -.goal-tree-prerequisite-field, -.goal-tree-prerequisite-list { - display: grid; - gap: 7px; -} - -.goal-tree-prerequisite-list { - max-height: 150px; - overflow-y: auto; - padding-right: 4px; -} - -.goal-tree-prerequisite-item { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 8px; - align-items: center; - min-height: 32px; - padding: 6px 8px; - border: 1px solid rgba(165, 180, 252, 0.12); - border-radius: var(--radius-sm); - background: rgba(7, 10, 24, 0.28); -} - -.goal-tree-prerequisite-item input[type="checkbox"] { - display: grid; - width: 14px; - min-width: 14px; - height: 14px; - min-height: 14px; - margin: 0; - place-items: center; - appearance: none; - border: 1px solid rgba(165, 180, 252, 0.28); - border-radius: 3px; - background: rgba(7, 10, 24, 0.56); - accent-color: var(--color-accent-gold); -} - -.goal-tree-prerequisite-item input[type="checkbox"]:checked { - border-color: rgba(246, 196, 83, 0.66); - background: rgba(246, 196, 83, 0.22); -} - -.goal-tree-prerequisite-item input[type="checkbox"]:checked::before { - color: var(--color-text-primary); - font-size: 0.72rem; - font-weight: 900; - line-height: 1; - content: "✓"; -} - -.goal-tree-prerequisite-item input[type="checkbox"]:disabled { - opacity: 0.72; -} - -.goal-tree-prerequisite-item.is-primary { - border-color: rgba(246, 196, 83, 0.22); - background: rgba(246, 196, 83, 0.08); -} - -.goal-tree-prerequisite-item span { - overflow: hidden; - min-width: 0; - color: var(--color-text-secondary); - font-size: 0.78rem; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-color-field, -.goal-tree-icon-field { - display: grid; - gap: 7px; -} - -.goal-tree-icon-picker { - position: relative; -} - -.goal-tree-icon-picker-button { - display: grid; - width: 100%; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 8px; - align-items: center; - justify-content: start; - min-height: 42px; - text-align: left; -} - -.goal-tree-icon-picker-button > span:not(.goal-tree-icon-preview) { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-icon-preview { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid rgba(246, 196, 83, 0.24); - border-radius: var(--radius-sm); - background: rgba(246, 196, 83, 0.08); - color: var(--color-accent-gold); -} - -.goal-tree-icon-picker-menu { - position: absolute; - z-index: 12; - top: calc(100% + 6px); - left: 0; - display: grid; - width: min(260px, 72vw); - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 6px; - padding: 8px; - border: 1px solid rgba(165, 180, 252, 0.14); - border-radius: var(--radius-md); - background: rgba(7, 10, 24, 0.98); - box-shadow: var(--shadow-md); -} - -.goal-tree-icon-picker-menu button { - display: grid; - min-width: 0; - min-height: 38px; - place-items: center; - padding: 0; -} - -.goal-tree-icon-picker-menu button.active { - border-color: rgba(246, 196, 83, 0.62); - background: rgba(246, 196, 83, 0.1); - color: var(--color-accent-gold); -} - -.goal-tree-color-swatches { - display: flex; - flex-wrap: wrap; - gap: 7px; -} - -.goal-tree-color-swatch { - width: 28px; - min-width: 28px; - min-height: 28px; - padding: 0; - border-color: rgba(150, 165, 205, 0.18); - border-radius: var(--radius-sm); - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 42%), - var(--goal-tree-swatch-color); -} - -.goal-tree-color-swatch.active { - border-color: rgba(246, 196, 83, 0.76); - box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.15); -} - -.goal-tree-form-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - margin-top: var(--space-1); -} - -.goal-tree-form-actions button { - display: inline-flex; - min-height: 36px; - align-items: center; - justify-content: center; - gap: 6px; - padding: 0 12px; - font-size: 0.82rem; - font-weight: 800; - line-height: 1.2; - white-space: nowrap; -} - -.goal-tree-form-actions button:not(.danger) { - flex: 0 0 auto; - border-color: rgba(165, 180, 252, 0.14); - background: rgba(7, 10, 24, 0.28); - color: var(--color-text-secondary); -} - -.goal-tree-form-actions button.danger { - flex: 1 1 150px; -} - -.goal-tree-form-actions .ui-icon { - width: 15px; - height: 15px; -} - -.goal-tree-view.tool-split-results { - position: relative; - min-width: 0; - width: 100%; - max-width: 100%; - min-height: 260px; - overflow: hidden; - padding: 0; -} - -.goal-tree-scroll-area { - display: grid; - grid-template-columns: minmax(0, max-content); - grid-template-rows: auto minmax(0, max-content); - width: 100%; - height: 100%; - min-height: inherit; - align-content: start; - justify-content: start; - overflow-x: auto; - overflow-y: auto; - overscroll-behavior-x: contain; - overscroll-behavior-y: auto; - padding: var(--space-4); - background: - linear-gradient(rgba(150, 165, 205, 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgba(150, 165, 205, 0.035) 1px, transparent 1px), - radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08), transparent 34%), - rgba(7, 10, 24, 0.2); - background-size: - 28px 28px, - 28px 28px, - auto, - auto; -} - -.goal-tree-view.is-horizontal.is-editing .goal-tree-scroll-area { - grid-template-columns: 44px minmax(0, max-content); - grid-template-rows: minmax(260px, max-content); - column-gap: var(--space-3); -} - -.goal-tree-view.is-horizontal.is-editing .goal-tree-view-controls { - grid-column: 1; - grid-row: 1; -} - -.goal-tree-view.is-horizontal.is-editing .goal-tree-canvas-frame, -.goal-tree-view.is-horizontal.is-editing .goal-tree-empty { - grid-column: 2; - grid-row: 1; -} - -.goal-tree-view.is-vertical.is-editing .goal-tree-scroll-area { - grid-template-columns: minmax(0, 1fr); -} - -.goal-tree-view.is-vertical.is-editing .goal-tree-view-controls { - grid-column: 1; - grid-row: 1; - width: 100%; - max-width: none; - box-sizing: border-box; - padding-right: 84px; -} - -.goal-tree-view.is-vertical.is-editing .goal-tree-canvas-frame, -.goal-tree-view.is-vertical.is-editing .goal-tree-empty { - grid-column: 1; - grid-row: 2; -} - -.goal-tree-view:not(.is-editing) .goal-tree-canvas-frame { - margin-inline: auto; -} - -.goal-tree-view-controls { - position: relative; - z-index: 5; - display: flex; - align-items: flex-start; - gap: var(--space-2); - min-width: 0; - margin-bottom: var(--space-2); -} - -.goal-tree-view:not(.is-editing) .goal-tree-view-controls { - display: none; -} - -.goal-tree-view.is-horizontal .goal-tree-view-controls { - flex-direction: column; - align-self: stretch; - min-height: 100%; - margin-bottom: 0; -} - -.goal-tree-view.is-vertical .goal-tree-view-controls { - width: 100%; - max-width: 100%; -} - -.goal-tree-orientation-switch { - position: absolute; - z-index: 7; - top: var(--space-4); - right: var(--space-4); - display: inline-grid; - grid-template-columns: 16px 30px; - width: fit-content; - min-width: 70px; - min-height: 40px; - align-items: center; - flex: 0 0 auto; - gap: 7px; - padding: 0 7px 0 10px; - border-radius: var(--radius-pill); - box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16); -} - -.goal-tree-orientation-switch .ui-icon { - width: 16px; - height: 16px; -} - -.goal-tree-module.is-view-only { - grid-template-columns: 1fr; -} - -.goal-tree-canvas-frame { - position: relative; - width: var(--goal-tree-canvas-width, 100%); - min-width: min(100%, var(--goal-tree-canvas-width, 100%)); - height: var(--goal-tree-canvas-height, 1px); -} - -.goal-tree-canvas { - position: relative; - display: grid; - width: max-content; - min-width: 100%; - max-width: none; - align-items: start; - justify-items: center; - isolation: isolate; - transform: scale(var(--goal-tree-view-scale, 1)); - transform-origin: top left; -} - -.goal-tree-canvas-frame > .goal-tree-canvas { - position: absolute; - top: 0; - left: 0; -} - -.goal-tree-canvas.is-horizontal { - display: flex; - align-items: flex-start; - justify-items: start; -} - -.diagram-connectors { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: visible; - pointer-events: none; -} - -.goal-tree-connectors { - z-index: 1; - color: rgba(246, 196, 83, 0.58); -} - -.goal-tree-connectors path { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.35; - vector-effect: non-scaling-stroke; -} - -.goal-tree-list { - position: relative; - z-index: 2; - display: flex; - align-items: flex-start; - justify-content: center; - min-width: max-content; - margin: 0; - padding: 0; - gap: 24px; - list-style: none; -} - -.goal-tree-list.is-root-list { - padding: 8px; -} - -.goal-tree-list.is-child-list { - margin-top: 28px; - padding-top: 28px; -} - -.goal-tree-canvas.is-horizontal .goal-tree-list { - flex-direction: column; - align-items: flex-start; - gap: 20px; -} - -.goal-tree-canvas.is-horizontal .goal-tree-list.is-root-list { - padding: 8px 48px 8px 8px; -} - -.goal-tree-add-root-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - padding: 0; - border-style: dashed; - background: rgba(7, 10, 24, 0.72); - color: var(--color-text-secondary); - font-size: 0.84rem; - font-weight: 900; - white-space: nowrap; - box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16); -} - -.goal-tree-view.is-horizontal .goal-tree-add-root-button { - width: 44px; - min-width: 44px; - min-height: 360px; - flex: 1 1 360px; - flex-direction: row-reverse; - padding: 16px 0; - text-align: center; - writing-mode: vertical-rl; -} - -.goal-tree-view.is-horizontal .goal-tree-add-root-button span { - transform: rotate(180deg); -} - -.goal-tree-view.is-horizontal .goal-tree-add-root-button .ui-icon { - transform: rotate(180deg); -} - -.goal-tree-view.is-vertical .goal-tree-add-root-button { - width: min(520px, 100%); - max-width: 100%; - min-width: min(390px, 100%); - min-height: 40px; - flex: 0 1 520px; - padding: 0 16px; -} - -.goal-tree-add-root-button .ui-icon { - width: 15px; - height: 15px; -} - -.goal-tree-canvas.is-horizontal .goal-tree-list.is-child-list { - margin-top: 0; - margin-left: 34px; - padding-top: 0; - padding-left: 34px; -} - -.goal-tree-node { - position: relative; - display: flex; - --goal-tree-card-color: var(--color-accent-gold); - min-width: 104px; - flex-direction: column; - align-items: center; -} - -.goal-tree-canvas.is-horizontal .goal-tree-node { - flex-direction: row; - align-items: center; -} - -.goal-tree-node.is-text { - --goal-tree-card-color: var(--color-accent-cyan); -} - -.goal-tree-node.is-checklist { - --goal-tree-card-color: #34d399; -} - -.goal-tree-node-card { - position: relative; - z-index: 1; - display: grid; - width: 104px; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr); - grid-template-areas: - "main"; - gap: 6px; - align-items: stretch; - align-content: stretch; - min-width: 0; - min-height: 104px; - padding: 7px; - border: 1px solid color-mix(in srgb, var(--goal-tree-card-color) 38%, rgba(150, 165, 205, 0.16)); - border-radius: var(--radius-lg); - background: - linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 10%, transparent), transparent 44%), - linear-gradient(160deg, rgba(139, 92, 246, 0.12), rgba(16, 20, 38, 0.72)); - cursor: pointer; - box-shadow: - 0 12px 22px rgba(0, 0, 0, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.04); - transition: - border-color var(--duration-fast) var(--ease-standard), - background var(--duration-fast) var(--ease-standard), - box-shadow var(--duration-fast) var(--ease-standard); -} - -.goal-tree-node-card.has-actions { - grid-template-rows: minmax(0, 1fr) 22px; - grid-template-areas: - "main" - "actions"; -} - -.goal-tree-node-hover-actions { - position: absolute; - z-index: 3; - right: 6px; - bottom: 7px; - left: 6px; - display: flex; - justify-content: space-between; - gap: 4px; - opacity: 0; - pointer-events: none; - transition: opacity var(--duration-fast) var(--ease-standard); -} - -.goal-tree-node-card:hover .goal-tree-node-hover-actions, -.goal-tree-node-card:focus-within .goal-tree-node-hover-actions { - opacity: 1; -} - -.goal-tree-node-hover-actions button { - width: 24px; - min-width: 24px; - min-height: 24px; - padding: 0; - border-color: rgba(165, 180, 252, 0.14); - background: rgba(7, 10, 24, 0.78); - pointer-events: none; -} - -.goal-tree-node-card:hover .goal-tree-node-hover-actions button, -.goal-tree-node-card:focus-within .goal-tree-node-hover-actions button { - pointer-events: auto; -} - -.goal-tree-node-hover-actions .ui-icon { - width: 13px; - height: 13px; -} - -.goal-tree-node.is-text, -.goal-tree-node.is-checklist { - min-width: 184px; -} - -.goal-tree-node.is-text > .goal-tree-node-card, -.goal-tree-node.is-checklist > .goal-tree-node-card { - width: 184px; -} - -.goal-tree-node-card.is-viewing { - grid-template-columns: minmax(0, 1fr); -} - -.goal-tree-node.is-selected > .goal-tree-node-card { - border-color: color-mix(in srgb, var(--goal-tree-card-color) 72%, rgba(246, 196, 83, 0.24)); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 22%, transparent), - 0 12px 22px rgba(0, 0, 0, 0.18); -} - -.goal-tree-node.is-selected > .goal-tree-node-card.is-editing { - border-color: color-mix(in srgb, var(--goal-tree-card-color) 86%, var(--color-accent-gold)); - background: - linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 20%, transparent), transparent 48%), - linear-gradient(160deg, rgba(139, 92, 246, 0.18), rgba(16, 20, 38, 0.82)); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--goal-tree-card-color) 36%, transparent), - 0 0 22px color-mix(in srgb, var(--goal-tree-card-color) 18%, transparent), - 0 16px 28px rgba(0, 0, 0, 0.22); -} - -.goal-tree-node.is-complete > .goal-tree-node-card { - border-color: color-mix(in srgb, var(--goal-tree-card-color) 54%, rgba(34, 211, 238, 0.3)); - background: - linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 12%, transparent), transparent 44%), - linear-gradient(160deg, rgba(139, 92, 246, 0.1), rgba(16, 20, 38, 0.72)); -} - -.goal-tree-node-card:hover, -.goal-tree-node-card:focus-within { - border-color: color-mix(in srgb, var(--goal-tree-card-color) 68%, rgba(246, 196, 83, 0.24)); - background: - linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), transparent 48%), - linear-gradient(160deg, rgba(139, 92, 246, 0.14), rgba(16, 20, 38, 0.78)); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), - 0 14px 26px rgba(0, 0, 0, 0.2); -} - -.goal-tree-node-main { - grid-area: main; - display: grid; - align-content: start; - justify-items: stretch; - gap: 5px; - min-width: 0; - min-height: 46px; - padding: 5px; - border: 0; - background: transparent; - color: var(--color-text-primary); - cursor: pointer; - text-align: center; - box-shadow: none; -} - -.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main { - justify-items: center; - min-height: 58px; -} - -.goal-tree-node-main:hover, -.goal-tree-node-main:focus { - border-color: transparent; - background: transparent; - box-shadow: none; -} - -.goal-tree-node-heading { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 6px; - align-items: center; - min-width: 0; -} - -.goal-tree-node-icon { - display: grid; - width: 24px; - height: 24px; - place-items: center; - border: 1px solid color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 45%, transparent); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 14%, transparent); - color: var(--goal-tree-node-color, var(--color-accent-gold)); -} - -.goal-tree-node-icon .ui-icon { - width: 15px; - height: 15px; -} - -.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon { - width: 42px; - height: 42px; -} - -.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon .ui-icon { - width: 27px; - height: 27px; -} - -.goal-tree-node-title { - overflow: hidden; - min-width: 0; - color: var(--color-text-primary); - font-size: 0.78rem; - font-weight: 900; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-node-content-preview { - display: grid; - width: 100%; - min-height: 0; - gap: 3px; - justify-items: stretch; - color: var(--color-text-primary); -} - -.goal-tree-node-content-preview:empty { - display: none; -} - -.goal-tree-node-content-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 4px; - align-items: center; - min-width: 0; - font-size: 0.72rem; - font-weight: 800; - text-align: left; -} - -.goal-tree-node.is-checklist .goal-tree-node-content-row { - grid-template-columns: auto minmax(0, 1fr); -} - -.goal-tree-node.is-text .goal-tree-node-content-row { - grid-template-columns: auto minmax(0, 1fr); -} - -.goal-tree-node.is-text .goal-tree-node-content-row::before { - content: ""; - width: 3px; - height: 3px; - border-radius: 50%; - background: currentColor; -} - -.goal-tree-node-content-row strong { - color: var(--color-accent-gold); - font-size: 0.72rem; -} - -.goal-tree-node-content-label { - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.goal-tree-node-content-add-button { - display: grid; - width: 100%; - min-height: 20px; - padding: 0; - place-items: center; - border-style: dashed; - border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 56%, transparent); - border-radius: var(--radius-sm); - background: rgba(7, 10, 24, 0.2); - color: var(--color-text-secondary); -} - -.goal-tree-node-content-add-button:hover, -.goal-tree-node-content-add-button:focus-visible { - border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 78%, transparent); - background: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 14%, transparent); - color: var(--color-text-primary); -} - -.goal-tree-node-content-add-button .ui-icon { - width: 12px; - height: 12px; -} - -.goal-tree-node.is-checklist .goal-tree-node-content-label { - padding-left: 2px; -} - -.goal-tree-node-check { - display: grid; - width: 10px; - height: 10px; - margin: 0; - accent-color: var(--color-accent-cyan); -} - -.goal-tree-step-check { - justify-self: center; - width: 16px; - height: 16px; - margin: 0; - accent-color: var(--color-accent-gold); -} - -.goal-tree-step-qty-stepper { - justify-self: center; - grid-template-columns: 18px minmax(34px, auto) 18px; - gap: 2px; - font-size: 0.68rem; -} - -.goal-tree-step-qty-stepper button { - display: grid; - width: 18px; - min-width: 18px; - height: 18px; - min-height: 18px; - padding: 0; - place-items: center; -} - -.goal-tree-step-qty-stepper .ui-icon { - width: 10px; - height: 10px; -} - -.goal-tree-node-content-row .goal-tree-content-qty-stepper { - justify-self: start; - grid-template-columns: 16px minmax(30px, auto) 16px; - gap: 2px; - font-size: 0.64rem; -} - -.goal-tree-node-content-row .goal-tree-content-qty-stepper button { - min-width: 16px; - min-height: 16px; - padding: 0; -} - -.goal-tree-node-content-row .goal-tree-content-qty-stepper .ui-icon { - width: 9px; - height: 9px; -} - -.goal-tree-progress-track { - grid-column: 1 / -1; - justify-self: stretch; - width: 100%; - height: 5px; - overflow: hidden; - border-radius: var(--radius-pill); - background: rgba(150, 165, 205, 0.13); -} - -.goal-tree-progress-track span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--color-accent-gold), var(--color-accent-cyan)); -} - -.goal-tree-node-actions { - grid-area: actions; - display: flex; - min-height: 22px; - justify-content: center; - align-items: center; - gap: 4px; - padding-top: 0; -} - -.goal-tree-node-actions button, -.goal-tree-drag-handle { - width: 22px; - min-width: 22px; - height: 22px; - min-height: 22px; - padding: 0; -} - -.goal-tree-node-actions .goal-tree-branch-toggle { - width: 22px; - min-width: 22px; - height: 22px; - min-height: 22px; -} - -.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-down, -.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-up { - transform: rotate(-90deg); -} - -.goal-tree-drag-handle { - position: absolute; - z-index: 3; - top: 6px; - left: 6px; - border-color: rgba(165, 180, 252, 0.34); - background: rgba(7, 10, 24, 0.82); - color: var(--color-text-primary); - cursor: grab; - opacity: 1; - box-shadow: - 0 0 0 1px rgba(7, 10, 24, 0.72), - 0 6px 12px rgba(0, 0, 0, 0.22); -} - -.goal-tree-drag-handle:hover { - border-color: rgba(246, 196, 83, 0.42); - background: rgba(7, 10, 24, 0.92); - color: var(--color-text-primary); - box-shadow: - 0 0 0 1px rgba(246, 196, 83, 0.12), - 0 8px 14px rgba(0, 0, 0, 0.26); -} - -.goal-tree-drag-handle .ui-icon { - width: 12px; - height: 12px; -} - -.goal-tree-node-tooltip { - max-width: 220px; -} - -.goal-tree-node.is-dragging { - opacity: 0.55; -} - -.goal-tree-node.is-drop-target > .goal-tree-node-card { - border-color: rgba(246, 196, 83, 0.48); -} - -.goal-tree-node.is-drop-target > .goal-tree-node-card::after { - position: absolute; - border-radius: var(--radius-pill); - background: rgba(246, 196, 83, 0.9); - box-shadow: 0 0 12px rgba(246, 196, 83, 0.24); - content: ""; - pointer-events: none; -} - -.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target > .goal-tree-node-card::after { - top: 8px; - bottom: 8px; - left: -8px; - width: 3px; -} - -.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target.drop-after > .goal-tree-node-card::after { - right: -8px; - left: auto; -} - -.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target > .goal-tree-node-card::after { - top: -8px; - right: 8px; - left: 8px; - height: 3px; -} - -.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target.drop-after > .goal-tree-node-card::after { - top: auto; - bottom: -8px; -} - -.goal-tree-empty { - padding: var(--space-4); - color: var(--color-text-secondary); - text-align: center; -} - -.goal-tree-module .text-import-actions { - padding-top: var(--space-2); -} - -@media (max-width: 860px) { - .goal-tree-module { - grid-template-columns: 1fr; - } - - .goal-tree-view.tool-split-results { - min-height: 220px; - } - - .goal-tree-list { - gap: 16px; - } - - .goal-tree-node { - min-width: 96px; - } - - .goal-tree-node-card { - width: 96px; - } - - .goal-tree-node.is-text, - .goal-tree-node.is-checklist { - min-width: 160px; - } - - .goal-tree-node.is-text > .goal-tree-node-card, - .goal-tree-node.is-checklist > .goal-tree-node-card { - width: 160px; - } -} +// Rôle : assemble les styles de l'outil arbre d'objectifs par responsabilité. +@use "goal-tree/editor"; +@use "goal-tree/view"; +@use "goal-tree/nodes"; diff --git a/website/src/styles/toolboxes/goal-tree/_editor.scss b/website/src/styles/toolboxes/goal-tree/_editor.scss new file mode 100644 index 0000000..df539d7 --- /dev/null +++ b/website/src/styles/toolboxes/goal-tree/_editor.scss @@ -0,0 +1,401 @@ +// Rôle : stylise les panneaux d'édition et contrôles de l'arbre d'objectifs. +.goal-tree-module { + display: grid; + grid-template-columns: minmax(320px, 40fr) minmax(0, 60fr); + column-gap: var(--space-6); + row-gap: var(--space-4); + align-items: start; + overflow: hidden; + padding: var(--space-4); +} + +.module[data-module-type="goalTree"] { + contain: paint; + overflow: hidden; +} + +.module[data-module-type="goalTree"] > .module-content { + min-width: 0; +} + +.module[data-module-type="goalTree"] > .module-content.is-scrollable { + overflow: auto; +} + +.goal-tree-editor { + align-content: start; + gap: var(--space-3); +} + +.goal-tree-editor label, +.goal-tree-selected-form label { + display: grid; + gap: 5px; + min-width: 0; + color: var(--color-text-secondary); + font-size: 0.82rem; + font-weight: 700; +} + +.goal-tree-editor input, +.goal-tree-editor select { + min-width: 0; + width: 100%; +} + +.goal-tree-field-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.goal-tree-selected-form > button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.goal-tree-selected-form { + display: grid; + gap: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid rgba(150, 165, 205, 0.14); +} + +.goal-tree-form-tabs { + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); +} + +.goal-tree-form-tabs button { + min-height: 34px; + gap: 6px; + padding: 0 8px; + font-size: 0.78rem; +} + +.goal-tree-form-tabs .module-icon-svg { + width: 15px; + height: 15px; + flex: 0 0 15px; +} + +.goal-tree-form-tabs .module-icon-settings { + width: 16px; + height: 16px; + flex-basis: 16px; + transform: scale(1.12); +} + +.goal-tree-form-tabs button > span:not(.module-icon-svg) { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-form-panel { + display: grid; + gap: var(--space-3); +} + +.goal-tree-form-section-title, +.goal-tree-color-field > span, +.goal-tree-icon-field > span { + color: var(--color-text-secondary); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.goal-tree-content-editor { + display: grid; + gap: 8px; +} + +.goal-tree-content-editor-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 30px; + gap: 6px; + align-items: center; +} + +.goal-tree-content-editor-row.is-checklist { + grid-template-columns: minmax(0, 1fr) 30px; + grid-template-areas: + "total delete" + "label delete"; +} + +.goal-tree-content-label-input { + grid-area: label; +} + +.goal-tree-content-formula-input, +.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap { + grid-area: total; +} + +.goal-tree-content-editor-row.is-checklist button { + grid-area: delete; + align-self: stretch; +} + +.goal-tree-content-editor-row > input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.goal-tree-content-editor-row button { + min-width: 30px; + min-height: 30px; + padding: 0; +} + +.goal-tree-content-qty-stepper { + display: grid; + grid-template-columns: 30px minmax(42px, auto) 30px; + gap: 4px; + align-items: center; + color: var(--color-text-secondary); + font-size: 0.78rem; + font-weight: 800; + white-space: nowrap; +} + +.goal-tree-content-qty-stepper > span { + text-align: center; +} + +.goal-tree-content-hint { + margin: 0; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.goal-tree-content-add-button { + display: inline-flex; + justify-content: center; + gap: 7px; + border-style: dashed; +} + +.tool-split-scroll-toggle.goal-tree-progress-mode-toggle { + grid-template-columns: 18px minmax(0, 1fr) 34px; + width: 100%; + justify-items: start; +} + +.tool-split-scroll-toggle.goal-tree-progress-mode-toggle span:not(.ui-icon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-progress-mode-hint { + margin: -2px 0 0; + color: var(--color-text-muted); + font-size: 0.76rem; + line-height: 1.35; +} + +.goal-tree-prerequisite-field, +.goal-tree-prerequisite-list { + display: grid; + gap: 7px; +} + +.goal-tree-prerequisite-list { + max-height: 150px; + overflow-y: auto; + padding-right: 4px; +} + +.goal-tree-prerequisite-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + min-height: 32px; + padding: 6px 8px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-sm); + background: rgba(7, 10, 24, 0.28); +} + +.goal-tree-prerequisite-item input[type="checkbox"] { + display: grid; + width: 14px; + min-width: 14px; + height: 14px; + min-height: 14px; + margin: 0; + place-items: center; + appearance: none; + border: 1px solid rgba(165, 180, 252, 0.28); + border-radius: 3px; + background: rgba(7, 10, 24, 0.56); + accent-color: var(--color-accent-gold); +} + +.goal-tree-prerequisite-item input[type="checkbox"]:checked { + border-color: rgba(246, 196, 83, 0.66); + background: rgba(246, 196, 83, 0.22); +} + +.goal-tree-prerequisite-item input[type="checkbox"]:checked::before { + color: var(--color-text-primary); + font-size: 0.72rem; + font-weight: 900; + line-height: 1; + content: "✓"; +} + +.goal-tree-prerequisite-item input[type="checkbox"]:disabled { + opacity: 0.72; +} + +.goal-tree-prerequisite-item.is-primary { + border-color: rgba(246, 196, 83, 0.22); + background: rgba(246, 196, 83, 0.08); +} + +.goal-tree-prerequisite-item span { + overflow: hidden; + min-width: 0; + color: var(--color-text-secondary); + font-size: 0.78rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-color-field, +.goal-tree-icon-field { + display: grid; + gap: 7px; +} + +.goal-tree-icon-picker { + position: relative; +} + +.goal-tree-icon-picker-button { + display: grid; + width: 100%; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + justify-content: start; + min-height: 42px; + text-align: left; +} + +.goal-tree-icon-picker-button > span:not(.goal-tree-icon-preview) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-icon-preview { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid rgba(246, 196, 83, 0.24); + border-radius: var(--radius-sm); + background: rgba(246, 196, 83, 0.08); + color: var(--color-accent-gold); +} + +.goal-tree-icon-picker-menu { + position: absolute; + z-index: 12; + top: calc(100% + 6px); + left: 0; + display: grid; + width: min(260px, 72vw); + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + padding: 8px; + border: 1px solid rgba(165, 180, 252, 0.14); + border-radius: var(--radius-md); + background: rgba(7, 10, 24, 0.98); + box-shadow: var(--shadow-md); +} + +.goal-tree-icon-picker-menu button { + display: grid; + min-width: 0; + min-height: 38px; + place-items: center; + padding: 0; +} + +.goal-tree-icon-picker-menu button.active { + border-color: rgba(246, 196, 83, 0.62); + background: rgba(246, 196, 83, 0.1); + color: var(--color-accent-gold); +} + +.goal-tree-color-swatches { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.goal-tree-color-swatch { + width: 28px; + min-width: 28px; + min-height: 28px; + padding: 0; + border-color: rgba(150, 165, 205, 0.18); + border-radius: var(--radius-sm); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 42%), + var(--goal-tree-swatch-color); +} + +.goal-tree-color-swatch.active { + border-color: rgba(246, 196, 83, 0.76); + box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.15); +} + +.goal-tree-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: space-between; + margin-top: var(--space-1); +} + +.goal-tree-form-actions button { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + font-size: 0.82rem; + font-weight: 800; + line-height: 1.2; + white-space: nowrap; +} + +.goal-tree-form-actions button:not(.danger) { + flex: 0 0 auto; + border-color: rgba(165, 180, 252, 0.14); + background: rgba(7, 10, 24, 0.28); + color: var(--color-text-secondary); +} + +.goal-tree-form-actions button.danger { + flex: 1 1 150px; +} + +.goal-tree-form-actions .ui-icon { + width: 15px; + height: 15px; +} diff --git a/website/src/styles/toolboxes/goal-tree/_nodes.scss b/website/src/styles/toolboxes/goal-tree/_nodes.scss new file mode 100644 index 0000000..aa553db --- /dev/null +++ b/website/src/styles/toolboxes/goal-tree/_nodes.scss @@ -0,0 +1,523 @@ +// Rôle : stylise les cartes, contenus et états de drag de l'arbre d'objectifs. +.goal-tree-node { + position: relative; + display: flex; + --goal-tree-card-color: var(--color-accent-gold); + min-width: 104px; + flex-direction: column; + align-items: center; +} + +.goal-tree-canvas.is-horizontal .goal-tree-node { + flex-direction: row; + align-items: center; +} + +.goal-tree-node.is-text { + --goal-tree-card-color: var(--color-accent-cyan); +} + +.goal-tree-node.is-checklist { + --goal-tree-card-color: #34d399; +} + +.goal-tree-node-card { + position: relative; + z-index: 1; + display: grid; + width: 104px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + grid-template-areas: + "main"; + gap: 6px; + align-items: stretch; + align-content: stretch; + min-width: 0; + min-height: 104px; + padding: 7px; + border: 1px solid color-mix(in srgb, var(--goal-tree-card-color) 38%, rgba(150, 165, 205, 0.16)); + border-radius: var(--radius-lg); + background: + linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 10%, transparent), transparent 44%), + linear-gradient(160deg, rgba(139, 92, 246, 0.12), rgba(16, 20, 38, 0.72)); + cursor: pointer; + box-shadow: + 0 12px 22px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + transition: + border-color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.goal-tree-node-card.has-actions { + grid-template-rows: minmax(0, 1fr) 22px; + grid-template-areas: + "main" + "actions"; +} + +.goal-tree-node-hover-actions { + position: absolute; + z-index: 3; + right: 6px; + bottom: 7px; + left: 6px; + display: flex; + justify-content: space-between; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.goal-tree-node-card:hover .goal-tree-node-hover-actions, +.goal-tree-node-card:focus-within .goal-tree-node-hover-actions { + opacity: 1; +} + +.goal-tree-node-hover-actions button { + width: 24px; + min-width: 24px; + min-height: 24px; + padding: 0; + border-color: rgba(165, 180, 252, 0.14); + background: rgba(7, 10, 24, 0.78); + pointer-events: none; +} + +.goal-tree-node-card:hover .goal-tree-node-hover-actions button, +.goal-tree-node-card:focus-within .goal-tree-node-hover-actions button { + pointer-events: auto; +} + +.goal-tree-node-hover-actions .ui-icon { + width: 13px; + height: 13px; +} + +.goal-tree-node.is-text, +.goal-tree-node.is-checklist { + min-width: 184px; +} + +.goal-tree-node.is-text > .goal-tree-node-card, +.goal-tree-node.is-checklist > .goal-tree-node-card { + width: 184px; +} + +.goal-tree-node-card.is-viewing { + grid-template-columns: minmax(0, 1fr); +} + +.goal-tree-node.is-selected > .goal-tree-node-card { + border-color: color-mix(in srgb, var(--goal-tree-card-color) 72%, rgba(246, 196, 83, 0.24)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 22%, transparent), + 0 12px 22px rgba(0, 0, 0, 0.18); +} + +.goal-tree-node.is-selected > .goal-tree-node-card.is-editing { + border-color: color-mix(in srgb, var(--goal-tree-card-color) 86%, var(--color-accent-gold)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 20%, transparent), transparent 48%), + linear-gradient(160deg, rgba(139, 92, 246, 0.18), rgba(16, 20, 38, 0.82)); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--goal-tree-card-color) 36%, transparent), + 0 0 22px color-mix(in srgb, var(--goal-tree-card-color) 18%, transparent), + 0 16px 28px rgba(0, 0, 0, 0.22); +} + +.goal-tree-node.is-complete > .goal-tree-node-card { + border-color: color-mix(in srgb, var(--goal-tree-card-color) 54%, rgba(34, 211, 238, 0.3)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 12%, transparent), transparent 44%), + linear-gradient(160deg, rgba(139, 92, 246, 0.1), rgba(16, 20, 38, 0.72)); +} + +.goal-tree-node-card:hover, +.goal-tree-node-card:focus-within { + border-color: color-mix(in srgb, var(--goal-tree-card-color) 68%, rgba(246, 196, 83, 0.24)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), transparent 48%), + linear-gradient(160deg, rgba(139, 92, 246, 0.14), rgba(16, 20, 38, 0.78)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), + 0 14px 26px rgba(0, 0, 0, 0.2); +} + +.goal-tree-node-main { + grid-area: main; + display: grid; + align-content: start; + justify-items: stretch; + gap: 5px; + min-width: 0; + min-height: 46px; + padding: 5px; + border: 0; + background: transparent; + color: var(--color-text-primary); + cursor: pointer; + text-align: center; + box-shadow: none; +} + +.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main { + justify-items: center; + min-height: 58px; +} + +.goal-tree-node-main:hover, +.goal-tree-node-main:focus { + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.goal-tree-node-heading { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px; + align-items: center; + min-width: 0; +} + +.goal-tree-node-icon { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 45%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 14%, transparent); + color: var(--goal-tree-node-color, var(--color-accent-gold)); +} + +.goal-tree-node-icon .ui-icon { + width: 15px; + height: 15px; +} + +.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon { + width: 42px; + height: 42px; +} + +.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon .ui-icon { + width: 27px; + height: 27px; +} + +.goal-tree-node-title { + overflow: hidden; + min-width: 0; + color: var(--color-text-primary); + font-size: 0.78rem; + font-weight: 900; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-node-content-preview { + display: grid; + width: 100%; + min-height: 0; + gap: 3px; + justify-items: stretch; + color: var(--color-text-primary); +} + +.goal-tree-node-content-preview:empty { + display: none; +} + +.goal-tree-node-content-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px; + align-items: center; + min-width: 0; + font-size: 0.72rem; + font-weight: 800; + text-align: left; +} + +.goal-tree-node.is-checklist .goal-tree-node-content-row { + grid-template-columns: auto minmax(0, 1fr); +} + +.goal-tree-node.is-text .goal-tree-node-content-row { + grid-template-columns: auto minmax(0, 1fr); +} + +.goal-tree-node.is-text .goal-tree-node-content-row::before { + content: ""; + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; +} + +.goal-tree-node-content-row strong { + color: var(--color-accent-gold); + font-size: 0.72rem; +} + +.goal-tree-node-content-label { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-tree-node-content-add-button { + display: grid; + width: 100%; + min-height: 20px; + padding: 0; + place-items: center; + border-style: dashed; + border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 56%, transparent); + border-radius: var(--radius-sm); + background: rgba(7, 10, 24, 0.2); + color: var(--color-text-secondary); +} + +.goal-tree-node-content-add-button:hover, +.goal-tree-node-content-add-button:focus-visible { + border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 78%, transparent); + background: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 14%, transparent); + color: var(--color-text-primary); +} + +.goal-tree-node-content-add-button .ui-icon { + width: 12px; + height: 12px; +} + +.goal-tree-node.is-checklist .goal-tree-node-content-label { + padding-left: 2px; +} + +.goal-tree-node-check { + display: grid; + width: 10px; + height: 10px; + margin: 0; + accent-color: var(--color-accent-cyan); +} + +.goal-tree-step-check { + justify-self: center; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-accent-gold); +} + +.goal-tree-step-qty-stepper { + justify-self: center; + grid-template-columns: 18px minmax(34px, auto) 18px; + gap: 2px; + font-size: 0.68rem; +} + +.goal-tree-step-qty-stepper button { + display: grid; + width: 18px; + min-width: 18px; + height: 18px; + min-height: 18px; + padding: 0; + place-items: center; +} + +.goal-tree-step-qty-stepper .ui-icon { + width: 10px; + height: 10px; +} + +.goal-tree-node-content-row .goal-tree-content-qty-stepper { + justify-self: start; + grid-template-columns: 16px minmax(30px, auto) 16px; + gap: 2px; + font-size: 0.64rem; +} + +.goal-tree-node-content-row .goal-tree-content-qty-stepper button { + min-width: 16px; + min-height: 16px; + padding: 0; +} + +.goal-tree-node-content-row .goal-tree-content-qty-stepper .ui-icon { + width: 9px; + height: 9px; +} + +.goal-tree-progress-track { + grid-column: 1 / -1; + justify-self: stretch; + width: 100%; + height: 5px; + overflow: hidden; + border-radius: var(--radius-pill); + background: rgba(150, 165, 205, 0.13); +} + +.goal-tree-progress-track span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--color-accent-gold), var(--color-accent-cyan)); +} + +.goal-tree-node-actions { + grid-area: actions; + display: flex; + min-height: 22px; + justify-content: center; + align-items: center; + gap: 4px; + padding-top: 0; +} + +.goal-tree-node-actions button, +.goal-tree-drag-handle { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; + padding: 0; +} + +.goal-tree-node-actions .goal-tree-branch-toggle { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; +} + +.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-down, +.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-up { + transform: rotate(-90deg); +} + +.goal-tree-drag-handle { + position: absolute; + z-index: 3; + top: 6px; + left: 6px; + border-color: rgba(165, 180, 252, 0.34); + background: rgba(7, 10, 24, 0.82); + color: var(--color-text-primary); + cursor: grab; + opacity: 1; + box-shadow: + 0 0 0 1px rgba(7, 10, 24, 0.72), + 0 6px 12px rgba(0, 0, 0, 0.22); +} + +.goal-tree-drag-handle:hover { + border-color: rgba(246, 196, 83, 0.42); + background: rgba(7, 10, 24, 0.92); + color: var(--color-text-primary); + box-shadow: + 0 0 0 1px rgba(246, 196, 83, 0.12), + 0 8px 14px rgba(0, 0, 0, 0.26); +} + +.goal-tree-drag-handle .ui-icon { + width: 12px; + height: 12px; +} + +.goal-tree-node-tooltip { + max-width: 220px; +} + +.goal-tree-node.is-dragging { + opacity: 0.55; +} + +.goal-tree-node.is-drop-target > .goal-tree-node-card { + border-color: rgba(246, 196, 83, 0.48); +} + +.goal-tree-node.is-drop-target > .goal-tree-node-card::after { + position: absolute; + border-radius: var(--radius-pill); + background: rgba(246, 196, 83, 0.9); + box-shadow: 0 0 12px rgba(246, 196, 83, 0.24); + content: ""; + pointer-events: none; +} + +.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target > .goal-tree-node-card::after { + top: 8px; + bottom: 8px; + left: -8px; + width: 3px; +} + +.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target.drop-after > .goal-tree-node-card::after { + right: -8px; + left: auto; +} + +.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target > .goal-tree-node-card::after { + top: -8px; + right: 8px; + left: 8px; + height: 3px; +} + +.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target.drop-after > .goal-tree-node-card::after { + top: auto; + bottom: -8px; +} + +.goal-tree-empty { + padding: var(--space-4); + color: var(--color-text-secondary); + text-align: center; +} + +.goal-tree-module .text-import-actions { + padding-top: var(--space-2); +} + +@media (max-width: 860px) { + .goal-tree-module { + grid-template-columns: 1fr; + } + + .goal-tree-view.tool-split-results { + min-height: 220px; + } + + .goal-tree-list { + gap: 16px; + } + + .goal-tree-node { + min-width: 96px; + } + + .goal-tree-node-card { + width: 96px; + } + + .goal-tree-node.is-text, + .goal-tree-node.is-checklist { + min-width: 160px; + } + + .goal-tree-node.is-text > .goal-tree-node-card, + .goal-tree-node.is-checklist > .goal-tree-node-card { + width: 160px; + } +} diff --git a/website/src/styles/toolboxes/goal-tree/_view.scss b/website/src/styles/toolboxes/goal-tree/_view.scss new file mode 100644 index 0000000..01f2028 --- /dev/null +++ b/website/src/styles/toolboxes/goal-tree/_view.scss @@ -0,0 +1,270 @@ +// Rôle : stylise la zone de rendu, le canevas et les connecteurs de l'arbre d'objectifs. +.goal-tree-view.tool-split-results { + position: relative; + min-width: 0; + width: 100%; + max-width: 100%; + min-height: 260px; + overflow: hidden; + padding: 0; +} + +.goal-tree-scroll-area { + display: grid; + grid-template-columns: minmax(0, max-content); + grid-template-rows: auto minmax(0, max-content); + width: 100%; + height: 100%; + min-height: inherit; + align-content: start; + justify-content: start; + overflow-x: auto; + overflow-y: auto; + overscroll-behavior-x: contain; + overscroll-behavior-y: auto; + padding: var(--space-4); + background: + linear-gradient(rgba(150, 165, 205, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(150, 165, 205, 0.035) 1px, transparent 1px), + radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08), transparent 34%), + rgba(7, 10, 24, 0.2); + background-size: + 28px 28px, + 28px 28px, + auto, + auto; +} + +.goal-tree-view.is-horizontal.is-editing .goal-tree-scroll-area { + grid-template-columns: 44px minmax(0, max-content); + grid-template-rows: minmax(260px, max-content); + column-gap: var(--space-3); +} + +.goal-tree-view.is-horizontal.is-editing .goal-tree-view-controls { + grid-column: 1; + grid-row: 1; +} + +.goal-tree-view.is-horizontal.is-editing .goal-tree-canvas-frame, +.goal-tree-view.is-horizontal.is-editing .goal-tree-empty { + grid-column: 2; + grid-row: 1; +} + +.goal-tree-view.is-vertical.is-editing .goal-tree-scroll-area { + grid-template-columns: minmax(0, 1fr); +} + +.goal-tree-view.is-vertical.is-editing .goal-tree-view-controls { + grid-column: 1; + grid-row: 1; + width: 100%; + max-width: none; + box-sizing: border-box; + padding-right: 84px; +} + +.goal-tree-view.is-vertical.is-editing .goal-tree-canvas-frame, +.goal-tree-view.is-vertical.is-editing .goal-tree-empty { + grid-column: 1; + grid-row: 2; +} + +.goal-tree-view:not(.is-editing) .goal-tree-canvas-frame { + margin-inline: auto; +} + +.goal-tree-view-controls { + position: relative; + z-index: 5; + display: flex; + align-items: flex-start; + gap: var(--space-2); + min-width: 0; + margin-bottom: var(--space-2); +} + +.goal-tree-view:not(.is-editing) .goal-tree-view-controls { + display: none; +} + +.goal-tree-view.is-horizontal .goal-tree-view-controls { + flex-direction: column; + align-self: stretch; + min-height: 100%; + margin-bottom: 0; +} + +.goal-tree-view.is-vertical .goal-tree-view-controls { + width: 100%; + max-width: 100%; +} + +.goal-tree-orientation-switch { + position: absolute; + z-index: 7; + top: var(--space-4); + right: var(--space-4); + display: inline-grid; + grid-template-columns: 16px 30px; + width: fit-content; + min-width: 70px; + min-height: 40px; + align-items: center; + flex: 0 0 auto; + gap: 7px; + padding: 0 7px 0 10px; + border-radius: var(--radius-pill); + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16); +} + +.goal-tree-orientation-switch .ui-icon { + width: 16px; + height: 16px; +} + +.goal-tree-module.is-view-only { + grid-template-columns: 1fr; +} + +.goal-tree-canvas-frame { + position: relative; + width: var(--goal-tree-canvas-width, 100%); + min-width: min(100%, var(--goal-tree-canvas-width, 100%)); + height: var(--goal-tree-canvas-height, 1px); +} + +.goal-tree-canvas { + position: relative; + display: grid; + width: max-content; + min-width: 100%; + max-width: none; + align-items: start; + justify-items: center; + isolation: isolate; + transform: scale(var(--goal-tree-view-scale, 1)); + transform-origin: top left; +} + +.goal-tree-canvas-frame > .goal-tree-canvas { + position: absolute; + top: 0; + left: 0; +} + +.goal-tree-canvas.is-horizontal { + display: flex; + align-items: flex-start; + justify-items: start; +} + +.diagram-connectors { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.goal-tree-connectors { + z-index: 1; + color: rgba(246, 196, 83, 0.58); +} + +.goal-tree-connectors path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.35; + vector-effect: non-scaling-stroke; +} + +.goal-tree-list { + position: relative; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: center; + min-width: max-content; + margin: 0; + padding: 0; + gap: 24px; + list-style: none; +} + +.goal-tree-list.is-root-list { + padding: 8px; +} + +.goal-tree-list.is-child-list { + margin-top: 28px; + padding-top: 28px; +} + +.goal-tree-canvas.is-horizontal .goal-tree-list { + flex-direction: column; + align-items: flex-start; + gap: 20px; +} + +.goal-tree-canvas.is-horizontal .goal-tree-list.is-root-list { + padding: 8px 48px 8px 8px; +} + +.goal-tree-add-root-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0; + border-style: dashed; + background: rgba(7, 10, 24, 0.72); + color: var(--color-text-secondary); + font-size: 0.84rem; + font-weight: 900; + white-space: nowrap; + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16); +} + +.goal-tree-view.is-horizontal .goal-tree-add-root-button { + width: 44px; + min-width: 44px; + min-height: 360px; + flex: 1 1 360px; + flex-direction: row-reverse; + padding: 16px 0; + text-align: center; + writing-mode: vertical-rl; +} + +.goal-tree-view.is-horizontal .goal-tree-add-root-button span { + transform: rotate(180deg); +} + +.goal-tree-view.is-horizontal .goal-tree-add-root-button .ui-icon { + transform: rotate(180deg); +} + +.goal-tree-view.is-vertical .goal-tree-add-root-button { + width: min(520px, 100%); + max-width: 100%; + min-width: min(390px, 100%); + min-height: 40px; + flex: 0 1 520px; + padding: 0 16px; +} + +.goal-tree-add-root-button .ui-icon { + width: 15px; + height: 15px; +} + +.goal-tree-canvas.is-horizontal .goal-tree-list.is-child-list { + margin-top: 0; + margin-left: 34px; + padding-top: 0; + padding-left: 34px; +}