Selectionner un element

Cliquez sur un element pour afficher ses informations.
`; const filename = `workflow-${slugifyTitle(workflowName)}.html`; downloadTextFile(html, filename, "text/html"); } function isArrowEnabled(connection) { if (!connection) return false; const value = connection.lineArrow; if (value === false || value === 0) return false; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "false" || normalized === "0" || normalized === "no") { return false; } } return true; } function isDashed(connection) { if (!connection) return false; const value = connection.lineStyle; if (typeof value === "string") { return value.trim().toLowerCase() === "dashed"; } return value === "dashed"; } function ensureArrowMarker(svg, color) { if (!svg) return "arrow"; const markerEl = svg.querySelector("marker#arrow"); if (color === "#0e9cef" && markerEl) { const markerPath = markerEl.querySelector("path"); if (markerPath) { markerPath.setAttribute("fill", color); } return "arrow"; } const defs = svg.querySelector("defs") || (() => { const defsEl = document.createElementNS("http://www.w3.org/2000/svg", "defs"); svg.appendChild(defsEl); return defsEl; })(); const id = `arrow-${Math.abs(hashString(color))}`; let marker = defs.querySelector(`marker#${id}`); if (!marker) { marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); marker.setAttribute("id", id); marker.setAttribute("markerWidth", "10"); marker.setAttribute("markerHeight", "10"); marker.setAttribute("refX", "10"); marker.setAttribute("refY", "5"); marker.setAttribute("orient", "auto"); marker.setAttribute("markerUnits", "userSpaceOnUse"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M0,0 L10,5 L0,10 Z"); path.setAttribute("fill", color); marker.appendChild(path); defs.appendChild(marker); } return id; } function shouldUseRawPoints(start, end, points) { if (!points || points.length < 2) return false; const threshold = 18; const first = points[0]; const last = points[points.length - 1]; return ( getPointDistance(first, start) <= threshold && getPointDistance(last, end) <= threshold ); } function getAnchorPoint(shape, target) { const centerX = shape.x + shape.width / 2; const centerY = shape.y + shape.height / 2; const targetX = target.x + target.width / 2; const targetY = target.y + target.height / 2; const dx = targetX - centerX; const dy = targetY - centerY; if (Math.abs(dx) > Math.abs(dy)) { return { x: dx > 0 ? shape.x + shape.width : shape.x, y: centerY }; } return { x: centerX, y: dy > 0 ? shape.y + shape.height : shape.y }; } function getAnchorPointFromAnchor(shape, anchor) { const offset = 0; switch (anchor) { case "tl": return { x: shape.x - offset, y: shape.y - offset }; case "tr": return { x: shape.x + shape.width + offset, y: shape.y - offset }; case "bl": return { x: shape.x - offset, y: shape.y + shape.height + offset }; case "br": return { x: shape.x + shape.width + offset, y: shape.y + shape.height + offset }; case "top": return { x: shape.x + shape.width / 2, y: shape.y - offset }; case "bottom": return { x: shape.x + shape.width / 2, y: shape.y + shape.height + offset }; case "left": return { x: shape.x - offset, y: shape.y + shape.height / 2 }; case "right": return { x: shape.x + shape.width + offset, y: shape.y + shape.height / 2 }; default: return { x: shape.x + shape.width / 2, y: shape.y + shape.height / 2 }; } } function getExitVector(start, end, anchor) { const fixedConnectorOffset = 20; if (anchor === "left") return { dx: -fixedConnectorOffset, dy: 0 }; if (anchor === "right") return { dx: fixedConnectorOffset, dy: 0 }; if (anchor === "top") return { dx: 0, dy: -fixedConnectorOffset }; if (anchor === "bottom") return { dx: 0, dy: fixedConnectorOffset }; const dx = end.x - start.x; const dy = end.y - start.y; if (Math.abs(dx) >= Math.abs(dy)) { return { dx: dx >= 0 ? fixedConnectorOffset : -fixedConnectorOffset, dy: 0 }; } return { dx: 0, dy: dy >= 0 ? fixedConnectorOffset : -fixedConnectorOffset }; } function buildFixedPoints(start, end, startAnchor, endAnchor) { const startVector = getExitVector(start, end, startAnchor); const endVector = getExitVector(end, start, endAnchor); const startFixed = { x: start.x + startVector.dx, y: start.y + startVector.dy }; const endFixed = { x: end.x + endVector.dx, y: end.y + endVector.dy }; return { startFixed, endFixed }; } function buildBasePoints(start, end, startAnchor, endAnchor) { const { startFixed, endFixed } = buildFixedPoints( start, end, startAnchor, endAnchor ); const sameRow = Math.abs(startFixed.y - endFixed.y) < 1; const sameCol = Math.abs(startFixed.x - endFixed.x) < 1; const points = [start, startFixed]; if (sameRow) { points.push({ x: (startFixed.x + endFixed.x) / 2, y: startFixed.y }); } else if (sameCol) { points.push({ x: startFixed.x, y: (startFixed.y + endFixed.y) / 2 }); } else { points.push({ x: startFixed.x, y: endFixed.y }); } points.push(endFixed, end); return ensureOrthogonal(points); } function buildPathWithPoints(points) { return points .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) .join(" "); } function getPathMidPoint(points) { let total = 0; const lengths = []; for (let i = 0; i < points.length - 1; i += 1) { const dx = points[i + 1].x - points[i].x; const dy = points[i + 1].y - points[i].y; const len = Math.hypot(dx, dy); lengths.push(len); total += len; } const half = total / 2; let acc = 0; for (let i = 0; i < lengths.length; i += 1) { if (acc + lengths[i] >= half) { const ratio = (half - acc) / lengths[i]; return { x: points[i].x + (points[i + 1].x - points[i].x) * ratio, y: points[i].y + (points[i + 1].y - points[i].y) * ratio }; } acc += lengths[i]; } return points[Math.floor(points.length / 2)]; } function clearWorkflow() { shapesHost.innerHTML = ""; Array.from(workflowSvg.querySelectorAll("path, g")) .filter((el) => !el.closest("defs")) .forEach((el) => el.remove()); shapeLookup = new Map(); } function getWorkflowLabel(shape) { if (!shape) return ""; if (typeof shape.workflow === "string") return shape.workflow; if (shape.workflow && typeof shape.workflow === "object") { return shape.workflow.name || shape.workflow.title || shape.workflow.path || ""; } return shape.workflowName || ""; } function getBlockDisplayName(shape, block) { return formatHeaderTitle( block?.name || block?.tutorial?.title || shape?.text || getWorkflowLabel(shape) || "Block" ); } function colorWithAlpha(color, alpha) { if (!color || !color.startsWith("#")) return color; const hex = color.replace("#", ""); if (hex.length !== 6) return color; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function getWorkflowPath(shape) { if (!shape) return ""; if (typeof shape.workflow === "string") return shape.workflow; if (shape.workflow && typeof shape.workflow === "object") { const direct = shape.workflow.path || shape.workflow.file || shape.workflow.src || ""; if (direct) return direct; const byName = workflowNameMap.get( shape.workflow.name || shape.workflow.title || shape.workflow.id || "" ); return byName || ""; } const byName = workflowNameMap.get(getWorkflowLabel(shape)); return shape.workflowPath || byName || ""; } function resolveWorkflowPath(shape) { const direct = getWorkflowPath(shape); if (!direct) return ""; if (!workflowFileSet.size) return direct; if (workflowFileSet.has(direct)) return direct; const label = getWorkflowLabel(shape); return workflowNameMap.get(label) || direct; } function normalizeBlockPath(path) { const cleaned = String(path || "") .replace(/^\.\/+/, "") .replace(/^\/+/, "") .trim(); if (!cleaned) return ""; return cleaned.startsWith("block/") ? cleaned : `block/${cleaned}`; } function normalizeWorkflowPath(path) { return String(path || "") .replace(/^\.\/+/, "") .replace(/^\/+/, "") .trim(); } async function hydrateWorkflowSubflows(data) { if (!data || !Array.isArray(data.shapes)) return false; let changed = false; await Promise.all(data.shapes.map(async (shape) => { if (shape?.workflow) return; const blockPath = getBlockPath(shape); if (!blockPath) return; const block = await fetchBlockData(blockPath); if (!block?.workflow) return; shape.workflow = block.workflow; changed = true; })); return changed; } function resolveWorkflowPathFromBlock(block) { if (!block?.workflow) return ""; if (typeof block.workflow === "string") { const normalized = normalizeWorkflowPath(block.workflow); return normalized || workflowNameMap.get(block.workflow) || ""; } if (typeof block.workflow === "object") { const direct = normalizeWorkflowPath( block.workflow.path || block.workflow.file || block.workflow.src || "" ); if (direct) return direct; const name = block.workflow.name || block.workflow.title || block.workflow.id || ""; return workflowNameMap.get(name) || ""; } return ""; } function normalizeInlinePath(path) { return String(path || "") .replace(/^\.\/+/, "") .replace(/^\/+/, "") .trim(); } function getInlineWorkflow(path) { if (!inlineWorkflows) return null; const key = normalizeInlinePath(path); return inlineWorkflows.get(key) || null; } function getInlineBlock(path) { if (!inlineBlocks) return null; const key = normalizeBlockPath(path); return inlineBlocks.get(key) || null; } function getBlockPath(shape) { if (!shape) return ""; const direct = normalizeBlockPath(shape.blockPath); if (direct) return direct; const category = String(shape.blockCategory || "").replace(/^\/+|\/+$/g, ""); let fileName = String(shape.blockFileName || "").replace(/^\/+|\/+$/g, ""); if (!fileName) return ""; if (!fileName.endsWith(".json")) { fileName += ".json"; } return normalizeBlockPath(category ? `${category}/${fileName}` : fileName); } async function fetchBlockData(path) { if (!path) return null; const inlineBlock = getInlineBlock(path); if (inlineBlock) return inlineBlock; if (blockInflight.has(path)) return blockInflight.get(path); const promise = fetch(path, { cache: "no-store" }) .then((response) => (response.ok ? response.json() : null)) .catch(() => null) .then((data) => { blockCache.set(path, data); blockInflight.delete(path); return data; }); blockInflight.set(path, promise); return promise; } async function fetchWorkflowData(path) { if (!path) return null; if (workflowCache.has(path)) return workflowCache.get(path); if (workflowInflight.has(path)) return workflowInflight.get(path); const promise = fetchWorkflowSource(path) .catch(() => null) .then((data) => { workflowCache.set(path, data); workflowInflight.delete(path); return data; }); workflowInflight.set(path, promise); return promise; } async function fetchWorkflowSource(path) { if (generatedWorkflows.has(path)) { return generatedWorkflows.get(path); } const inlineWorkflow = getInlineWorkflow(path); if (inlineWorkflow) { return inlineWorkflow; } const response = await fetch(path); if (response.ok) { return response.json(); } return null; } function renderMiniWorkflow(host, data) { if (!host) return; host.innerHTML = ""; if (!data || !Array.isArray(data.shapes) || !data.shapes.length) { const note = document.createElement("div"); note.className = "mini-empty"; note.textContent = "Workflow introuvable"; host.appendChild(note); return; } const width = host.clientWidth; const height = host.clientHeight; if (!width || !height) { requestAnimationFrame(() => renderMiniWorkflow(host, data)); return; } const padding = 6; const maxX = Math.max(...data.shapes.map((shape) => shape.x + shape.width), 1); const maxY = Math.max(...data.shapes.map((shape) => shape.y + shape.height), 1); const scaleX = (width - padding * 2) / maxX; const scaleY = (height - padding * 2) / maxY; const scale = Math.min(scaleX, scaleY, 1); const scaledShapes = new Map(); data.shapes.forEach((shape) => { scaledShapes.set(shape.id, { ...shape, x: shape.x * scale + padding, y: shape.y * scale + padding, width: shape.width * scale, height: shape.height * scale }); }); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("shape-mini-lines"); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); host.appendChild(svg); (data.connections || []).forEach((connection) => { const from = scaledShapes.get(connection.from); const to = scaledShapes.get(connection.to); if (!from || !to) return; const lineColor = getConnectionLineColor(connection); const start = connection.startAnchor ? getAnchorPointFromAnchor(from, connection.startAnchor) : getAnchorPoint(from, to); const end = connection.endAnchor ? getAnchorPointFromAnchor(to, connection.endAnchor) : getAnchorPoint(to, from); const basePoints = buildBasePoints( start, end, connection.startAnchor, connection.endAnchor ); const points = ensureOrthogonal(basePoints); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", buildPathWithPoints(points)); path.setAttribute("fill", "none"); path.setAttribute("stroke", lineColor || "rgba(31, 41, 51, 0.5)"); path.setAttribute("stroke-width", "1.5"); if (isDashed(connection)) { path.setAttribute("stroke-dasharray", "4 3"); } svg.appendChild(path); }); data.shapes.forEach((shape) => { const scaled = scaledShapes.get(shape.id); if (!scaled) return; const node = document.createElement("div"); node.className = `mini-shape ${shape.type || "rect"}`; node.style.left = `${scaled.x}px`; node.style.top = `${scaled.y}px`; node.style.width = `${scaled.width}px`; node.style.height = `${scaled.height}px`; node.style.opacity = shape.opacity ?? 1; node.style.background = shape.bgColor || "rgba(14, 156, 239, 0.4)"; if (shape.type === "logo" && shape.imageData) { node.style.backgroundImage = `url(${shape.imageData})`; node.style.backgroundSize = "contain"; node.style.backgroundRepeat = "no-repeat"; node.style.backgroundPosition = "center"; node.style.backgroundColor = "#ffffff"; } host.appendChild(node); }); } function applyZoomTransform() { if (!workflowSvg || !shapesHost) return; if (!zoomActive) { workflowSvg.style.transform = ""; shapesHost.style.transform = ""; return; } const transform = `translate(${zoomTranslate.x}px, ${zoomTranslate.y}px) scale(${zoomScale})`; workflowSvg.style.transform = transform; shapesHost.style.transform = transform; } function setZoomCentered(scale) { if (!lastBounds) return; const content = lastContentBounds || { width: lastBounds.width, height: lastBounds.height }; const fitScale = Math.min( lastBounds.width / content.width, lastBounds.height / content.height, 1.5 ); zoomScale = Math.min(scale, fitScale); const contentWidth = content.width * zoomScale; const contentHeight = content.height * zoomScale; const centerX = (lastBounds.width - contentWidth) / 2; const centerY = (lastBounds.height - contentHeight) / 2; const minX = Math.min(0, lastBounds.width - contentWidth); const minY = Math.min(0, lastBounds.height - contentHeight); zoomTranslate = { x: Math.min(0, Math.max(centerX, minX)), y: Math.min(0, Math.max(centerY, minY)) }; zoomActive = true; zoomMode = "center"; applyZoomTransform(); if (zoomResetButton) { zoomResetButton.disabled = false; } } function resetZoom() { zoomActive = false; zoomMode = "none"; zoomScale = 1; zoomTranslate = { x: 0, y: 0 }; applyZoomTransform(); if (zoomResetButton) { zoomResetButton.disabled = true; } } function zoomToShape(shapeId) { if (!shapeId || !lastBounds) return; const shape = lastScaledShapes.get(shapeId); if (!shape) return; zoomScale = 1.8; const centerX = shape.x + shape.width / 2; const centerY = shape.y + shape.height / 2; zoomTranslate = { x: lastBounds.width / 2 - centerX * zoomScale, y: lastBounds.height / 2 - centerY * zoomScale }; zoomActive = true; zoomMode = "shape"; applyZoomTransform(); if (zoomResetButton) { zoomResetButton.disabled = false; } } function selectWorkflowByPath(path) { if (!workflowSelect || !path) return; const option = Array.from(workflowSelect.options) .find((entry) => entry.value === path); if (option) { workflowSelect.value = path; } } function readStoredWorkflow() { try { return localStorage.getItem(workflowStorageKey) || ""; } catch (error) { return ""; } } function storeWorkflow(path) { try { if (!path) return; localStorage.setItem(workflowStorageKey, path); } catch (error) { // ignore storage failures } } async function renderShapeDetails(shape) { if (!brickTitle || !brickContent) return; const title = shape.text || shape.id || "Element"; const blockPath = getBlockPath(shape); if (!blockPath && shape.tutorial) { renderTutorialDetails(shape.tutorial, title, shape.id); return; } if (!blockPath) { brickTitle.textContent = title; brickContent.innerHTML = `

${title}

`; updateBrickExportState({ title, payload: { title, content: title }, hasContent: true }); return; } const token = ++brickRenderToken; brickTitle.textContent = title; brickContent.innerHTML = "

Chargement du block...

"; updateBrickExportState({ title, payload: null, hasContent: false }); const block = await fetchBlockData(blockPath); if (token !== brickRenderToken) return; if (block?.tutorial) { const fallbackTitle = block.tutorial.title || block.name || title; renderTutorialDetails(block.tutorial, fallbackTitle, shape.id); return; } if (shape.tutorial) { renderTutorialDetails(shape.tutorial, title, shape.id); return; } brickTitle.textContent = title; brickContent.innerHTML = "

Aucun contenu de block disponible.

"; updateBrickExportState({ title, payload: null, hasContent: false }); } function clearSelection() { selectedShapeId = null; shapesHost?.querySelectorAll(".workflow-shape.selected") .forEach((el) => el.classList.remove("selected")); updateOutlineActive({}); if (brickTitle) { brickTitle.textContent = "Selectionner un element"; } if (brickContent) { brickContent.innerHTML = "
Cliquez sur un element pour afficher ses informations.
"; } updateBrickExportState({ title: "Selectionner un element", payload: null, hasContent: false }); } function renderTutorialDetails(tutorial, fallbackTitle, shapeId) { brickTitle.textContent = tutorial.title || fallbackTitle; brickContent.innerHTML = ""; if (Array.isArray(tutorial.introItems) && tutorial.introItems.length) { tutorial.introItems.forEach((item) => { if (item.type === "image") { if (!item.src) return; const imageWrap = document.createElement("div"); imageWrap.className = "brick-step-image"; imageWrap.style.width = `${item.widthPercent || 80}%`; const img = document.createElement("img"); img.src = item.src || ""; imageWrap.appendChild(img); const overlayLayer = document.createElement("div"); overlayLayer.className = "brick-overlay-layer"; (item.overlays || []).forEach((overlay) => { const overlayEl = document.createElement("div"); overlayEl.className = `brick-overlay ${overlay.type || "rect"}`; overlayEl.style.left = `${overlay.x || 0}%`; overlayEl.style.top = `${overlay.y || 0}%`; overlayEl.style.width = `${overlay.width || 10}%`; overlayEl.style.height = `${overlay.height || 10}%`; overlayEl.style.borderColor = overlay.color || "#ff3b30"; overlayEl.style.color = overlay.color || "#ff3b30"; if (overlay.type === "text") { const textColor = overlay.textColor || overlay.color || "#ff3b30"; overlayEl.textContent = overlay.text || ""; overlayEl.style.color = textColor; overlayEl.style.borderColor = textColor; overlayEl.style.fontSize = `${overlay.textSize || 12}px`; overlayEl.style.backgroundColor = overlay.textBgColor || "rgba(255, 255, 255, 0.85)"; } overlayLayer.appendChild(overlayEl); }); imageWrap.appendChild(overlayLayer); brickContent.appendChild(imageWrap); } else { const text = document.createElement("div"); text.className = "brick-step-text"; text.innerHTML = formatTutorialText(item.text || item.html || ""); brickContent.appendChild(text); } }); } else if (tutorial.intro) { const intro = document.createElement("p"); intro.className = "brick-note"; intro.textContent = tutorial.intro; brickContent.appendChild(intro); } const steps = Array.isArray(tutorial.steps) ? tutorial.steps : []; if (!steps.length) { return; } const list = document.createElement("div"); list.className = "brick-tutorial"; steps.forEach((step, index) => { renderTutorialStep(step, index, list, 0, shapeId, [index + 1]); }); brickContent.appendChild(list); updateBrickExportState({ title: tutorial.title || fallbackTitle, payload: buildTutorialExportPayload(tutorial, fallbackTitle), hasContent: true }); } function renderTutorialStep(step, index, host, depth, shapeId, path) { const details = document.createElement("details"); details.className = depth === 0 ? "brick-step" : "brick-substep"; const summary = document.createElement("summary"); summary.textContent = step.title || `Etape ${index + 1}`; details.appendChild(summary); const stepId = getStepAnchorId(shapeId, path); if (stepId) { details.id = stepId; } const body = document.createElement("div"); body.className = "brick-step-body"; const items = Array.isArray(step.items) ? step.items : step.type ? [step] : []; if (!items.length) { const empty = document.createElement("p"); empty.className = "brick-note"; empty.textContent = "Aucun contenu dans cette etape."; body.appendChild(empty); } else { items.forEach((entry) => { if (entry.type === "image") { if (!entry.src) { const empty = document.createElement("p"); empty.className = "brick-note"; empty.textContent = "Capture manquante pour cette etape."; body.appendChild(empty); return; } const imageWrap = document.createElement("div"); imageWrap.className = "brick-step-image"; imageWrap.style.width = `${entry.widthPercent || 80}%`; const img = document.createElement("img"); img.src = entry.src || ""; imageWrap.appendChild(img); const overlayLayer = document.createElement("div"); overlayLayer.className = "brick-overlay-layer"; (entry.overlays || []).forEach((overlay) => { const overlayEl = document.createElement("div"); overlayEl.className = `brick-overlay ${overlay.type || "rect"}`; overlayEl.style.left = `${overlay.x || 0}%`; overlayEl.style.top = `${overlay.y || 0}%`; overlayEl.style.width = `${overlay.width || 10}%`; overlayEl.style.height = `${overlay.height || 10}%`; overlayEl.style.borderColor = overlay.color || "#ff3b30"; overlayLayer.appendChild(overlayEl); }); imageWrap.appendChild(overlayLayer); body.appendChild(imageWrap); } else { const text = document.createElement("div"); text.className = "brick-step-text"; text.innerHTML = formatTutorialText(entry.text || entry.html || ""); body.appendChild(text); } }); } if (Array.isArray(step.substeps) && step.substeps.length) { const sublist = document.createElement("div"); sublist.className = "brick-substeps"; step.substeps.forEach((substep, subIndex) => { renderTutorialStep( substep, subIndex, sublist, depth + 1, shapeId, [...path, subIndex + 1] ); }); body.appendChild(sublist); } details.appendChild(body); host.appendChild(details); } function createOutlineButton(label, options = {}) { const button = document.createElement("button"); button.type = "button"; button.className = `outline-link${options.secondary ? " secondary" : ""}`; button.textContent = label; if (options.shapeId) button.dataset.shapeId = options.shapeId; if (options.stepId) button.dataset.stepId = options.stepId; if (options.workflowPath) button.dataset.workflowPath = options.workflowPath; return button; } function appendTutorialOutlineSteps(steps, host, shapeId, workflowPath, pathPrefix) { if (!Array.isArray(steps) || !steps.length) return; steps.forEach((step, index) => { const path = [...pathPrefix, index + 1]; const title = step?.title || `Etape ${index + 1}`; const item = document.createElement("li"); const button = createOutlineButton(title, { secondary: true, shapeId, stepId: getStepAnchorId(shapeId, path), workflowPath }); item.appendChild(button); if (Array.isArray(step?.substeps) && step.substeps.length) { const sublist = document.createElement("ul"); sublist.className = "outline-sublist"; appendTutorialOutlineSteps(step.substeps, sublist, shapeId, workflowPath, path); item.appendChild(sublist); } host.appendChild(item); }); } async function buildOutlineForWorkflowData(data, host, depth, workflowPath, visited) { if (!data || !Array.isArray(data.shapes) || !host) return; const shapes = sortShapesForOutline(data.shapes); await Promise.all(shapes.map(async (shape) => { const item = document.createElement("li"); const title = getShapeTitle(shape); const button = createOutlineButton(title, { shapeId: shape.id, workflowPath }); item.appendChild(button); const sublist = document.createElement("ul"); sublist.className = "outline-sublist"; const blockPath = getBlockPath(shape); const blockData = blockPath ? await fetchBlockData(blockPath) : null; const tutorial = blockData?.tutorial || shape?.tutorial || null; if (tutorial?.steps?.length) { appendTutorialOutlineSteps(tutorial.steps, sublist, shape.id, workflowPath, []); } const stepsWorkflowPath = resolveStepsWorkflowPathFromShape(shape) || resolveStepsWorkflowPathFromBlock(blockData, shape.id); const explicitWorkflowPath = resolveWorkflowPath(shape) || resolveWorkflowPathFromBlock(blockData); const appendChildWorkflow = async (path, label) => { if (!path) return; const childItem = document.createElement("li"); const childButton = createOutlineButton(`Sous workflow: ${label}`, { secondary: true, workflowPath: path }); childItem.appendChild(childButton); if (depth < outlineMaxDepth && !visited.has(path)) { visited.add(path); const childList = document.createElement("ul"); childList.className = "outline-sublist"; const childData = await fetchWorkflowData(path); if (childData?.shapes?.length) { await buildOutlineForWorkflowData( childData, childList, depth + 1, path, visited ); childItem.appendChild(childList); } } sublist.appendChild(childItem); }; if (explicitWorkflowPath) { const label = formatHeaderTitle(getWorkflowLabel(shape)) || "Sous workflow"; await appendChildWorkflow(explicitWorkflowPath, label); } if (stepsWorkflowPath) { await appendChildWorkflow(stepsWorkflowPath, getBlockDisplayName(shape, blockData)); } if (sublist.childElementCount) { item.appendChild(sublist); } host.appendChild(item); })); } async function buildWorkflowOutline(data, workflowPath) { if (!workflowOutlineList) return; const renderToken = ++outlineRenderToken; workflowOutlineList.innerHTML = ""; if (!data || !Array.isArray(data.shapes) || !data.shapes.length) { toggleOutlineEmpty("Aucun sommaire pour le moment."); return; } toggleOutlineEmpty(""); const visited = new Set(); if (workflowPath) visited.add(workflowPath); await buildOutlineForWorkflowData(data, workflowOutlineList, 0, workflowPath, visited); if (renderToken !== outlineRenderToken) return; } async function navigateToOutlineTarget({ workflowPath, shapeId, stepId } = {}) { if (!workflowPath) { await selectShapeById(shapeId, { scrollToStepId: stepId, stepId, scrollToShape: true }); return; } if (workflowPath !== lastSource) { if (lastSource && lastSource !== workflowPath) { workflowStack.push(lastSource); } pendingZoomScale = 1.5; selectWorkflowByPath(workflowPath); await loadWorkflow(workflowPath); } if (shapeId) { await selectShapeById(shapeId, { scrollToStepId: stepId, stepId, scrollToShape: true }); return; } if (workflowCanvas) { workflowCanvas.scrollIntoView({ behavior: "smooth", block: "center" }); } } function renderWorkflow(data) { clearWorkflow(); if (!data || !Array.isArray(data.shapes) || !data.shapes.length) { emptyState.classList.remove("hidden"); clearOutline(); return; } emptyState.classList.add("hidden"); const bounds = workflowCanvas.getBoundingClientRect(); lastBounds = bounds; const paddingX = 30; const paddingTop = 50; const paddingBottom = 50; const minX = Math.min(...data.shapes.map((shape) => shape.x)); const minY = Math.min(...data.shapes.map((shape) => shape.y)); const maxX = Math.max(...data.shapes.map((shape) => shape.x + shape.width), 1); const maxY = Math.max(...data.shapes.map((shape) => shape.y + shape.height), 1); const contentWidthRaw = Math.max(maxX - minX, 1); const contentHeightRaw = Math.max(maxY - minY, 1); const scaleX = (bounds.width - paddingX * 2) / contentWidthRaw; const scaleY = (bounds.height - paddingTop - paddingBottom) / contentHeightRaw; const scale = Math.min(scaleX, scaleY, 1); const contentWidth = contentWidthRaw * scale; const contentHeight = contentHeightRaw * scale; const offsetX = Math.max(paddingX, (bounds.width - contentWidth) / 2); const offsetY = Math.max(paddingTop, (bounds.height - contentHeight) / 2); lastContentBounds = { width: contentWidth + offsetX * 2, height: contentHeight + paddingTop + paddingBottom }; const scaledShapes = new Map(); workflowRenderToken += 1; const renderToken = workflowRenderToken; data.shapes.forEach((shape) => { const scaled = { ...shape, x: (shape.x - minX) * scale + offsetX, y: (shape.y - minY) * scale + offsetY, width: shape.width * scale, height: shape.height * scale }; scaledShapes.set(shape.id, scaled); shapeLookup.set(shape.id, shape); const node = document.createElement("div"); node.className = `workflow-shape ${shape.type || "rect"}`; node.dataset.shapeId = shape.id; node.style.left = `${scaled.x}px`; node.style.top = `${scaled.y}px`; node.style.width = `${scaled.width}px`; node.style.height = `${scaled.height}px`; node.style.opacity = shape.opacity ?? 1; if (shape.bgColor) { node.style.background = shape.bgColor; } if (shape.textColor) { node.style.color = shape.textColor; } if (shape.type === "logo" && shape.imageData) { node.style.backgroundImage = `url(${shape.imageData})`; node.style.backgroundSize = "contain"; node.style.backgroundRepeat = "no-repeat"; node.style.backgroundPosition = "center"; node.style.backgroundColor = "#ffffff"; } const textEl = document.createElement("div"); textEl.className = "shape-text"; textEl.textContent = shape.text || ""; if (shape.type === "logo" && !shape.text) { textEl.style.display = "none"; } node.appendChild(textEl); const workflowPath = resolveWorkflowPath(shape) || resolveStepsWorkflowPathFromShape(shape); const applyWorkflowDecoration = (path) => { if (!path) return; node.classList.add("has-workflow"); if (shape.bgColor) { node.style.background = colorWithAlpha(shape.bgColor, 0.7); } if (showSubWorkflows) { node.classList.add("has-mini"); const mini = document.createElement("div"); mini.className = "shape-mini"; node.appendChild(mini); fetchWorkflowData(path).then((workflowData) => { if (renderToken !== workflowRenderToken) return; if (!node.isConnected) return; renderMiniWorkflow(mini, workflowData); }); } }; if (workflowPath) { applyWorkflowDecoration(workflowPath); } else { const blockPath = getBlockPath(shape); if (blockPath) { fetchBlockData(blockPath).then((block) => { if (renderToken !== workflowRenderToken) return; const blockWorkflowPath = resolveWorkflowPathFromBlock(block); const blockStepsPath = resolveStepsWorkflowPathFromBlock(block, shape.id); applyWorkflowDecoration(blockWorkflowPath || blockStepsPath); }); } } if (shape.id === selectedShapeId) { node.classList.add("selected"); } shapesHost.appendChild(node); }); lastScaledShapes = scaledShapes; const svgWidth = bounds.width; const svgHeight = bounds.height; workflowSvg.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`); (data.connections || []).forEach((connection) => { const from = scaledShapes.get(connection.from); const to = scaledShapes.get(connection.to); if (!from || !to) return; const lineColor = getConnectionLineColor(connection); const start = connection.startAnchor ? getAnchorPointFromAnchor(from, connection.startAnchor) : getAnchorPoint(from, to); const end = connection.endAnchor ? getAnchorPointFromAnchor(to, connection.endAnchor) : getAnchorPoint(to, from); const basePoints = buildBasePoints( start, end, connection.startAnchor, connection.endAnchor ); const rawPoints = Array.isArray(connection.points) && connection.points.length >= 2 ? connection.points : basePoints; const scaledRawPoints = Array.isArray(rawPoints) ? rawPoints.map((point) => ({ x: (point.x - minX) * scale + offsetX, y: (point.y - minY) * scale + offsetY })) : []; const normalizedRawPoints = ensureOrthogonal(scaledRawPoints); const points = shouldUseRawPoints(start, end, normalizedRawPoints) ? normalizedRawPoints : basePoints; const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", buildPathWithPoints(points)); path.setAttribute("fill", "none"); path.setAttribute("stroke", lineColor); path.setAttribute("stroke-width", "2"); if (isDashed(connection)) { path.setAttribute("stroke-dasharray", "6 4"); } if (isArrowEnabled(connection)) { const markerId = ensureArrowMarker(workflowSvg, lineColor); path.setAttribute("marker-end", `url(#${markerId})`); } workflowSvg.appendChild(path); if (connection.label && String(connection.label).trim()) { const mid = getPathMidPoint(points); const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); label.setAttribute("x", String(mid.x + (connection.labelDx || 0))); label.setAttribute("y", String(mid.y + (connection.labelDy || 0))); label.setAttribute("text-anchor", "middle"); label.setAttribute("dominant-baseline", "middle"); label.setAttribute("fill", connection.labelTextColor || "#1f2933"); label.textContent = connection.label; labelGroup.appendChild(label); workflowSvg.appendChild(labelGroup); } }); if (pendingZoomScale) { setZoomCentered(pendingZoomScale); pendingZoomScale = null; return; } if ( zoomActive && zoomMode === "shape" && selectedShapeId && !lastScaledShapes.has(selectedShapeId) ) { resetZoom(); } else { applyZoomTransform(); } } function normalizeIndexPayload(payload) { if (!payload) return []; if (Array.isArray(payload)) return payload; if (Array.isArray(payload.workflows)) return payload.workflows; return []; } function normalizeWorkflowEntry(entry, index) { if (!entry) return null; if (typeof entry === "string") { return { id: `workflow-${index}`, name: entry, file: entry }; } if (typeof entry === "object") { const name = entry.name || entry.title || entry.id || `Workflow ${index + 1}`; const file = entry.file || entry.path || entry.src; if (!file) return null; return { id: entry.id || `workflow-${index}`, name, file }; } return null; } function populateSelect(entries) { if (!workflowSelect) return; workflowSelect.innerHTML = ""; if (!entries.length) { const option = document.createElement("option"); option.value = ""; option.textContent = "Aucun workflow"; workflowSelect.appendChild(option); workflowSelect.disabled = true; return; } workflowSelect.disabled = false; entries.forEach((entry) => { const option = document.createElement("option"); option.value = entry.file; option.textContent = entry.name; workflowSelect.appendChild(option); }); } async function loadWorkflow(source) { try { const data = await fetchWorkflowSource(source); if (!data) { throw new Error("workflow_not_found"); } await hydrateWorkflowSubflows(data); lastWorkflow = data; lastSource = source; renderWorkflow(data); buildWorkflowOutline(data, source); } catch (error) { clearWorkflow(); clearOutline(); if (emptyState) { emptyState.textContent = `Impossible de charger ${source}.`; } emptyState.classList.remove("hidden"); } } async function loadWorkflowIndex() { try { const payload = inlineIndexPayload || await (async () => { const response = await fetch(workflowIndex); if (!response.ok) { throw new Error("workflow_index_not_found"); } return response.json(); })(); const entries = normalizeIndexPayload(payload) .map(normalizeWorkflowEntry) .filter(Boolean); if (!entries.length) { populateSelect([]); await loadWorkflow(workflowSrc); return; } populateSelect(entries); workflowNameMap.clear(); workflowFileSet.clear(); entries.forEach((entry) => { if (!entry?.file) return; if (entry.name) workflowNameMap.set(entry.name, entry.file); if (entry.id) workflowNameMap.set(entry.id, entry.file); workflowFileSet.add(entry.file); }); const stored = readStoredWorkflow(); const preferred = entries.find((entry) => entry.file === stored) || entries.find((entry) => entry.file === workflowSrc) || entries[0]; if (workflowSelect) { workflowSelect.value = preferred.file; } storeWorkflow(preferred.file); await loadWorkflow(preferred.file); } catch (error) { populateSelect([]); await loadWorkflow(workflowSrc); } } if (workflowSelect) { workflowSelect.addEventListener("change", () => { const value = workflowSelect.value; if (!value || value === lastSource) return; loadWorkflow(value); storeWorkflow(value); }); } if (workflowExportHtml) { workflowExportHtml.addEventListener("click", () => { exportSelectedWorkflowHtml().catch(() => { alert("Export HTML impossible."); }); }); } if (workflowExportHtmlCompressed) { workflowExportHtmlCompressed.addEventListener("click", () => { exportSelectedWorkflowHtml({ compressImages: true }).catch(() => { alert("Export HTML compresse impossible."); }); }); } if (subWorkflowToggle) { const syncToggleLabel = () => { subWorkflowToggle.textContent = showSubWorkflows ? "Masquer les sous workflows" : "Afficher les sous workflows"; subWorkflowToggle.classList.toggle("is-active", showSubWorkflows); }; syncToggleLabel(); subWorkflowToggle.addEventListener("click", () => { showSubWorkflows = !showSubWorkflows; syncToggleLabel(); if (lastWorkflow) { renderWorkflow(lastWorkflow); } }); } if (shapesHost) { shapesHost.addEventListener("click", (event) => { event.stopPropagation(); event.stopImmediatePropagation(); const node = event.target.closest(".workflow-shape"); if (!node) { clearSelection(); return; } const shapeId = node.dataset.shapeId; selectShapeById(shapeId, { scrollToShape: false }); }); shapesHost.addEventListener("dblclick", async (event) => { event.stopPropagation(); event.stopImmediatePropagation(); const node = event.target.closest(".workflow-shape"); if (!node) return; const shapeId = node.dataset.shapeId; const shape = shapeLookup.get(shapeId); const options = await getSubWorkflowOptionsForShape(shape); if (!options.length) return; let selectedPath = options[0].path; if (options.length > 1) { selectedPath = await showSubWorkflowModal(options); } if (!selectedPath) return; if (lastSource && lastSource !== selectedPath) { workflowStack.push(lastSource); } pendingZoomScale = 1.5; selectWorkflowByPath(selectedPath); loadWorkflow(selectedPath); }); } if (workflowOutline) { workflowOutline.addEventListener("click", (event) => { event.stopPropagation(); event.stopImmediatePropagation(); const button = event.target.closest(".outline-link"); if (!button) return; event.preventDefault(); const workflowPath = button.dataset.workflowPath || ""; const shapeId = button.dataset.shapeId || ""; const stepId = button.dataset.stepId || ""; navigateToOutlineTarget({ workflowPath: workflowPath || lastSource, shapeId, stepId }); }); } if (zoomResetButton) { zoomResetButton.disabled = true; zoomResetButton.addEventListener("click", () => { const previous = workflowStack.pop(); resetZoom(); if (previous) { selectWorkflowByPath(previous); loadWorkflow(previous); } }); } if (workflowTabButton) { workflowTabButton.addEventListener("click", () => { if (lastWorkflow) { renderWorkflow(lastWorkflow); } }); } if (brickExportPdf) { brickExportPdf.addEventListener("click", () => { if (!brickContent || !brickTitle) return; const title = brickTitle.textContent || currentBrickTitle || "Brick"; const contentClone = brickContent.cloneNode(true); contentClone.querySelectorAll("details").forEach((details) => { details.open = true; }); const printWindow = window.open("", "_blank"); if (!printWindow) return; const logoUrl = inlineLogos?.black || new URL("./assets/logotext-black.png", window.location.href).href; const html = buildPrintHtml(title, contentClone.innerHTML, logoUrl); printWindow.document.open(); printWindow.document.write(html); printWindow.document.close(); waitForImages(printWindow.document).then(() => { applyImagePageBreaks(printWindow.document); printWindow.focus(); printWindow.print(); }); }); } if (brickExportJson) { brickExportJson.addEventListener("click", () => { if (!brickContent) return; const title = currentBrickTitle || brickTitle?.textContent || "brick"; const payload = currentBrickPayload || { title, content: brickContent.textContent?.trim() || "" }; const filename = `${slugifyTitle(title)}.json`; const content = JSON.stringify(payload, null, 2); downloadTextFile(content, filename, "application/json"); }); } loadWorkflowIndex(); window.addEventListener("resize", () => { if (lastWorkflow) { renderWorkflow(lastWorkflow); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { clearSelection(); } }); }