This commit is contained in:
AlexBa16
2026-06-08 15:29:52 +02:00
commit 27903eed4a
9931 changed files with 1535659 additions and 0 deletions
@@ -0,0 +1,230 @@
@charset "UTF-8";
:root {
--wf-bg: var(--bg-primary);
--wf-border: var(--border-color);
--wf-border-hover: var(--border-color-translucent);
--wf-dot-color: var(--body-color);
--stage-bg: rgb(var(--primary-rgb));
--stage-border: var(--border-color);
--stage-text: var(--white);
--stage-virtual-bg: purple;
--transition-hover: var(--border-color-hover);
--transition-label-bg: #2071c6;
--transition-stroke: var(--transition-label-bg);
--transition-label-color: var(--white);
--transition-highlight: #ff6868;
--zoom-btn-bg: transparent;
--zoom-btn-hover: var(--border-color);
--zoom-btn-color: var(--text-primary);
--vf-custom-controls-bgcolor: var(--secondary);
--stage-desc: #ffffffc0 !important;
}
@keyframes dashmove {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -36px;
}
}
#workflow-graph {
cursor: grab;
background-image: radial-gradient(circle at 1px 1px, var(--wf-dot-color) 1px, transparent 1px);
border: 1px solid var(--stage-border);
background-size: 38px 38px;
border-radius: 8px;
width: 100%;
height: 80vh;
position: relative;
overflow: hidden;
}
#workflow-graph:active {
cursor: grabbing;
}
#workflow-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
#graph {
transform-origin: 0 0;
width: fit-content;
min-width: 100%;
min-height: 100%;
transition: transform .15s ease-out;
position: relative;
}
#graph.dragging {
transition: none;
}
#stages {
width: 100%;
height: 100%;
position: relative;
}
#connections {
z-index: 1;
pointer-events: none;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: visible;
}
.stage {
z-index: 10;
cursor: move;
user-select: none;
background: var(--stage-bg);
border: 1px solid var(--stage-border);
border-radius: 6px;
flex-direction: column;
justify-content: center;
width: 200px;
min-height: 80px;
padding: 12px 16px;
display: flex;
position: absolute;
box-shadow: 0 4px 10px #0009;
}
.stage:hover {
transform: translateY(-1px);
}
.stage.dragging {
z-index: 1000;
transform: rotate(1deg) scale(1.02);
}
.stage.virtual {
background: var(--stage-virtual-bg);
border-width: 1px;
}
.stage-title {
color: var(--stage-text);
word-wrap: break-word;
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.stage-description {
color: var(--stage-desc);
word-wrap: break-word;
margin: 0 0 8px;
font-size: 12px;
line-height: 1.4;
}
.stage-badge {
color: var(--stage-text);
text-transform: uppercase;
letter-spacing: .5px;
background: var(--stage-border);
border-radius: 3px;
align-self: flex-start;
padding: 2px 6px;
font-size: 10px;
font-weight: 500;
display: inline-block;
}
.transition-path {
stroke: var(--transition-stroke);
stroke-width: 3px;
fill: none;
opacity: .8;
transition: stroke .2s, stroke-width .2s;
}
.transition-path:hover {
stroke-width: 3px;
opacity: 1;
stroke: var(--transition-hover);
animation-duration: .5s;
}
.arrow-marker {
fill: var(--transition-stroke);
transition: fill .2s;
}
.transition-path:hover + .arrow-marker {
fill: var(--transition-hover);
}
.transition-path.highlighted {
stroke: var(--transition-highlight);
stroke-width: 5px;
stroke-dasharray: 12 6;
animation: .5s linear infinite dashmove;
}
.transition-label-content {
z-index: 20;
min-width: 80px;
max-width: 300px;
color: var(--transition-label-color);
text-align: center;
white-space: nowrap;
pointer-events: auto;
cursor: pointer;
user-select: none;
border: 2px solid var(--transition-stroke);
border-radius: 6px;
padding: 2px 12px;
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
transition: background .2s, border .2s, box-shadow .2s;
position: absolute;
box-shadow: 0 2px 8px #00000026;
background: var(--transition-label-bg) !important;
}
.transition-label-content.highlighted {
border-color: var(--transition-highlight);
font-weight: 700;
transition: transform .2s;
transform: scale(1.1);
}
.zoom-controls {
z-index: 1000;
background: var(--vf-custom-controls-bgcolor);
border: 1px solid var(--wf-border);
backdrop-filter: blur(4px);
border-radius: 6px;
flex-direction: column;
gap: 2px;
padding: 4px;
display: flex;
position: absolute;
bottom: 20px;
right: 20px;
}
.zoom-btn:hover {
background: var(--zoom-btn-hover);
transform: scale(1.05);
}
.zoom-btn:active {
transition: transform .5s;
transform: scale(.95);
}
@@ -0,0 +1 @@
@charset "UTF-8";:root{--wf-bg:var(--bg-primary);--wf-border:var(--border-color);--wf-border-hover:var(--border-color-translucent);--wf-dot-color:var(--body-color);--stage-bg:rgb(var(--primary-rgb));--stage-border:var(--border-color);--stage-text:var(--white);--stage-virtual-bg:purple;--transition-hover:var(--border-color-hover);--transition-label-bg:#2071c6;--transition-stroke:var(--transition-label-bg);--transition-label-color:var(--white);--transition-highlight:#ff6868;--zoom-btn-bg:transparent;--zoom-btn-hover:var(--border-color);--zoom-btn-color:var(--text-primary);--vf-custom-controls-bgcolor:var(--secondary);--stage-desc:#ffffffc0!important}@keyframes dashmove{0%{stroke-dashoffset:0}to{stroke-dashoffset:-36px}}#workflow-graph{cursor:grab;background-image:radial-gradient(circle at 1px 1px, var(--wf-dot-color) 1px, transparent 1px);border:1px solid var(--stage-border);background-size:38px 38px;border-radius:8px;width:100%;height:80vh;position:relative;overflow:hidden}#workflow-graph:active{cursor:grabbing}#workflow-container{width:100%;height:100%;position:relative;overflow:hidden}#graph{transform-origin:0 0;width:fit-content;min-width:100%;min-height:100%;transition:transform .15s ease-out;position:relative}#graph.dragging{transition:none}#stages{width:100%;height:100%;position:relative}#connections{z-index:1;pointer-events:none;width:100%;height:100%;position:absolute;top:0;left:0;overflow:visible}.stage{z-index:10;cursor:move;user-select:none;background:var(--stage-bg);border:1px solid var(--stage-border);border-radius:6px;flex-direction:column;justify-content:center;width:200px;min-height:80px;padding:12px 16px;display:flex;position:absolute;box-shadow:0 4px 10px #0009}.stage:hover{transform:translateY(-1px)}.stage.dragging{z-index:1000;transform:rotate(1deg)scale(1.02)}.stage.virtual{background:var(--stage-virtual-bg);border-width:1px}.stage-title{color:var(--stage-text);word-wrap:break-word;margin:0 0 6px;font-size:14px;font-weight:600;line-height:1.3}.stage-description{color:var(--stage-desc);word-wrap:break-word;margin:0 0 8px;font-size:12px;line-height:1.4}.stage-badge{color:var(--stage-text);text-transform:uppercase;letter-spacing:.5px;background:var(--stage-border);border-radius:3px;align-self:flex-start;padding:2px 6px;font-size:10px;font-weight:500;display:inline-block}.transition-path{stroke:var(--transition-stroke);stroke-width:3px;fill:none;opacity:.8;transition:stroke .2s,stroke-width .2s}.transition-path:hover{stroke-width:3px;opacity:1;stroke:var(--transition-hover);animation-duration:.5s}.arrow-marker{fill:var(--transition-stroke);transition:fill .2s}.transition-path:hover+.arrow-marker{fill:var(--transition-hover)}.transition-path.highlighted{stroke:var(--transition-highlight);stroke-width:5px;stroke-dasharray:12 6;animation:.5s linear infinite dashmove}.transition-label-content{z-index:20;min-width:80px;max-width:300px;color:var(--transition-label-color);text-align:center;white-space:nowrap;pointer-events:auto;cursor:pointer;user-select:none;border:2px solid var(--transition-stroke);border-radius:6px;padding:2px 12px;font-size:1rem;font-weight:600;line-height:1.2;transition:background .2s,border .2s,box-shadow .2s;position:absolute;box-shadow:0 2px 8px #00000026;background:var(--transition-label-bg)!important}.transition-label-content.highlighted{border-color:var(--transition-highlight);font-weight:700;transition:transform .2s;transform:scale(1.1)}.zoom-controls{z-index:1000;background:var(--vf-custom-controls-bgcolor);border:1px solid var(--wf-border);backdrop-filter:blur(4px);border-radius:6px;flex-direction:column;gap:2px;padding:4px;display:flex;position:absolute;bottom:20px;right:20px}.zoom-btn:hover{background:var(--zoom-btn-hover);transform:scale(1.05)}.zoom-btn:active{transition:transform .5s;transform:scale(.95)}
@@ -0,0 +1,199 @@
@charset "UTF-8";
:root {
--vf-black: #000;
--vf-custom-controls-bgcolor: var(--secondary);
--vf-edge-color: #2071c6;
--vf-white: #fff;
--vf-opacity-less: #0000001a;
--vf-opacity-more: #0009;
}
.vue-flow {
z-index: 0;
direction: ltr;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
[dir="rtl"] .vue-flow__node, [dir="rtl"] .edge-label, [dir="rtl"] .vue-flow__panel {
direction: rtl;
}
.vue-flow__container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.vue-flow__pane.draggable {
cursor: grab;
}
.vue-flow__pane.selection {
cursor: pointer;
}
.vue-flow__pane.dragging {
cursor: grabbing;
}
.vue-flow__transformationpane {
z-index: 2;
pointer-events: none;
transform-origin: 0 0;
}
.vue-flow__viewport {
z-index: 4;
overflow: clip;
}
.vue-flow__nodesselection-rect:focus, .vue-flow__nodesselection-rect:focus-visible, .vue-flow__edge.selected, .vue-flow__edge:focus, .vue-flow__edge:focus-visible {
outline: none;
}
.vue-flow .vue-flow__edges {
pointer-events: none;
overflow: visible;
}
.vue-flow__edge-path, .vue-flow__connection-path {
stroke: var(--success);
stroke-width: 3px;
fill: none;
}
.vue-flow__edge.animated path {
stroke-dasharray: 5;
animation: .5s linear infinite dashdraw;
}
.vue-flow__edge.animated path.vue-flow__edge-interaction {
stroke-dasharray: none;
animation: none;
}
.vue-flow__edge.inactive, .vue-flow__connection {
pointer-events: none;
}
.vue-flow__connection .animated {
stroke-dasharray: 5;
animation: .5s linear infinite dashdraw;
}
.vue-flow__connectionline {
z-index: 1001;
}
.vue-flow__nodes {
pointer-events: none;
transform-origin: 0 0;
}
.vue-flow__node {
box-sizing: border-box;
pointer-events: all;
cursor: default;
user-select: none;
transform-origin: 0 0;
position: absolute;
}
.vue-flow__handle.connectable {
pointer-events: all;
cursor: crosshair;
}
.vue-flow__panel {
z-index: 5;
margin: 15px;
position: absolute;
}
.vue-flow__panel.top {
top: 0;
}
.vue-flow__panel.bottom {
bottom: 0;
}
.vue-flow__panel.left {
left: 0;
}
.vue-flow__panel.right {
right: 0;
}
.vue-flow__panel.center {
left: 50%;
transform: translateX(-50%);
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10px;
}
}
.min-vh-80 {
height: 80vh;
}
.end-20-px {
right: 20px !important;
}
.top-25-px {
top: 25px !important;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.custom-edge {
background: var(--vf-edge-color) !important;
}
.vue-flow__minimap {
box-shadow: 0 10px 15px -3px var(--vf-opacity-less);
}
.custom-controls {
background: var(--vf-custom-controls-bgcolor);
bottom: 10px;
right: 10px;
}
.vue-flow__minimap.pannable {
cursor: grab;
}
.stage-node .edge-handler {
width: 12px !important;
height: 12px !important;
}
.stage-node:hover .vue-flow__handle {
opacity: 1;
}
.vue-flow__node-stage {
max-width: 250px !important;
}
.workflow-browser-actions-list {
background-color: var(--vf-black);
box-shadow: 0 4px 10px var(--vf-opacity-more);
}
@@ -0,0 +1 @@
@charset "UTF-8";:root{--vf-black:#000;--vf-custom-controls-bgcolor:var(--secondary);--vf-edge-color:#2071c6;--vf-white:#fff;--vf-opacity-less:#0000001a;--vf-opacity-more:#0009}.vue-flow{z-index:0;direction:ltr;width:100%;height:100%;position:relative;overflow:hidden}[dir=rtl] .vue-flow__node,[dir=rtl] .edge-label,[dir=rtl] .vue-flow__panel{direction:rtl}.vue-flow__container{width:100%;height:100%;position:absolute;top:0;left:0}.vue-flow__pane.draggable{cursor:grab}.vue-flow__pane.selection{cursor:pointer}.vue-flow__pane.dragging{cursor:grabbing}.vue-flow__transformationpane{z-index:2;pointer-events:none;transform-origin:0 0}.vue-flow__viewport{z-index:4;overflow:clip}.vue-flow__nodesselection-rect:focus,.vue-flow__nodesselection-rect:focus-visible,.vue-flow__edge.selected,.vue-flow__edge:focus,.vue-flow__edge:focus-visible{outline:none}.vue-flow .vue-flow__edges{pointer-events:none;overflow:visible}.vue-flow__edge-path,.vue-flow__connection-path{stroke:var(--success);stroke-width:3px;fill:none}.vue-flow__edge.animated path{stroke-dasharray:5;animation:.5s linear infinite dashdraw}.vue-flow__edge.animated path.vue-flow__edge-interaction{stroke-dasharray:none;animation:none}.vue-flow__edge.inactive,.vue-flow__connection{pointer-events:none}.vue-flow__connection .animated{stroke-dasharray:5;animation:.5s linear infinite dashdraw}.vue-flow__connectionline{z-index:1001}.vue-flow__nodes{pointer-events:none;transform-origin:0 0}.vue-flow__node{box-sizing:border-box;pointer-events:all;cursor:default;user-select:none;transform-origin:0 0;position:absolute}.vue-flow__handle.connectable{pointer-events:all;cursor:crosshair}.vue-flow__panel{z-index:5;margin:15px;position:absolute}.vue-flow__panel.top{top:0}.vue-flow__panel.bottom{bottom:0}.vue-flow__panel.left{left:0}.vue-flow__panel.right{right:0}.vue-flow__panel.center{left:50%;transform:translate(-50%)}@keyframes dashdraw{0%{stroke-dashoffset:10px}}.min-vh-80{height:80vh}.end-20-px{right:20px!important}.top-25-px{top:25px!important}.z-10{z-index:10}.z-20{z-index:20}.custom-edge{background:var(--vf-edge-color)!important}.vue-flow__minimap{box-shadow:0 10px 15px -3px var(--vf-opacity-less)}.custom-controls{background:var(--vf-custom-controls-bgcolor);bottom:10px;right:10px}.vue-flow__minimap.pannable{cursor:grab}.stage-node .edge-handler{width:12px!important;height:12px!important}.stage-node:hover .vue-flow__handle{opacity:1}.vue-flow__node-stage{max-width:250px!important}.workflow-browser-actions-list{background-color:var(--vf-black);box-shadow:0 4px 10px var(--vf-opacity-more)}
@@ -0,0 +1,57 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "com_workflow",
"version": "4.0.0",
"description": "Joomla CMS",
"license": "GPL-2.0-or-later",
"assets": [
{
"name": "com_workflow.admin-items-workflow-buttons",
"type": "script",
"uri": "com_workflow/admin-items-workflow-buttons.min.js",
"dependencies": [
"core"
],
"attributes": {
"type": "module"
},
"version": "34dbe2"
},
{
"name": "com_workflow.workflowgraph",
"type": "style",
"uri": "com_workflow/workflow-graph.min.css",
"version": "6f090d"
},
{
"name": "com_workflow.workflowgraphclient",
"type": "style",
"uri": "com_workflow/workflow-graph-client.min.css",
"version": "47a3eb"
},
{
"name": "com_workflow.workflowgraphclient",
"type": "script",
"uri": "com_workflow/workflow-graph-client.min.js",
"dependencies": [
"core"
],
"attributes": {
"type": "module"
},
"version": "41dd8e"
},
{
"name": "com_workflow.workflowgraph",
"type": "script",
"uri": "com_workflow/workflow-graph.min.js",
"dependencies": [
"core"
],
"attributes": {
"type": "module"
},
"version": "08f7bd"
}
]
}
@@ -0,0 +1,102 @@
/**
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
Joomla = window.Joomla || {};
/**
* Method that switches a given class to the following elements of the element provided
*
* @param {HTMLElement} element The reference element
* @param {string} className The class name to be toggled
*/
Joomla.toggleAllNextElements = (element, className) => {
const getNextSiblings = el => {
const siblings = [];
do {
siblings.push(el);
} while ((el = el.nextElementSibling) !== null);
return siblings;
};
const followingElements = getNextSiblings(element);
if (followingElements.length) {
followingElements.forEach(elem => {
if (elem.classList.contains(className)) {
elem.classList.remove(className);
} else {
elem.classList.add(className);
}
});
}
};
(() => {
document.addEventListener('DOMContentLoaded', () => {
const dropDownBtn = document.getElementById('toolbar-status-group');
if (!dropDownBtn) {
return;
}
const headline = dropDownBtn.querySelector('.button-transition-headline');
const separator = dropDownBtn.querySelector('.button-transition-separator');
const itemList = document.querySelector('table.itemList');
let itemListRows = [];
let transitionIds = [];
if (itemList) {
itemListRows = itemList.querySelectorAll('tbody tr');
}
function enableTransitions() {
if (transitionIds.length) {
let availableTrans = transitionIds.shift();
while (transitionIds.length) {
const compareTrans = transitionIds.shift();
availableTrans = availableTrans.filter(id => compareTrans.indexOf(id) !== -1);
}
if (availableTrans.length) {
if (headline) {
headline.classList.remove('d-none');
}
if (separator) {
separator.classList.remove('d-none');
}
}
availableTrans.forEach(trans => {
const elem = dropDownBtn.querySelector(`.transition-${trans}`);
if (elem) {
elem.parentNode.classList.remove('d-none');
}
});
}
}
// check for common attributes for which the conditions for a transition are possible or not
// and save this information in a boolean variable.
function collectTransitions(row) {
transitionIds.push(row.getAttribute('data-transitions').split(','));
}
// listen to click event to get selected rows
if (itemList) {
itemList.addEventListener('click', () => {
dropDownBtn.querySelectorAll('.button-transition').forEach(trans => {
trans.parentNode.classList.add('d-none');
});
if (headline) {
headline.classList.add('d-none');
}
if (separator) {
separator.classList.add('d-none');
}
transitionIds = [];
itemListRows.forEach(el => {
const checkedBox = el.querySelector('input[type=checkbox]');
if (checkedBox.checked) {
const parentTr = checkedBox.closest('tr');
collectTransitions(parentTr);
}
});
enableTransitions();
});
}
});
})();
@@ -0,0 +1,4 @@
/**
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/Joomla=window.Joomla||{},Joomla.toggleAllNextElements=(s,o)=>{const i=(t=>{const n=[];do n.push(t);while((t=t.nextElementSibling)!==null);return n})(s);i.length&&i.forEach(t=>{t.classList.contains(o)?t.classList.remove(o):t.classList.add(o)})},document.addEventListener("DOMContentLoaded",()=>{const s=document.getElementById("toolbar-status-group");if(!s)return;const o=s.querySelector(".button-transition-headline"),a=s.querySelector(".button-transition-separator"),i=document.querySelector("table.itemList");let t=[],n=[];i&&(t=i.querySelectorAll("tbody tr"));function c(){if(n.length){let e=n.shift();for(;n.length;){const l=n.shift();e=e.filter(r=>l.indexOf(r)!==-1)}e.length&&(o&&o.classList.remove("d-none"),a&&a.classList.remove("d-none")),e.forEach(l=>{const r=s.querySelector(`.transition-${l}`);r&&r.parentNode.classList.remove("d-none")})}}function d(e){n.push(e.getAttribute("data-transitions").split(","))}i&&i.addEventListener("click",()=>{s.querySelectorAll(".button-transition").forEach(e=>{e.parentNode.classList.add("d-none")}),o&&o.classList.add("d-none"),a&&a.classList.add("d-none"),n=[],t.forEach(e=>{const l=e.querySelector("input[type=checkbox]");if(l.checked){const r=l.closest("tr");d(r)}}),c()})});
@@ -0,0 +1,608 @@
function _extends() {
return _extends = Object.assign ? Object.assign.bind() : function (n) {
for (var e = 1; e < arguments.length; e++) {
var t = arguments[e];
for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
}
return n;
}, _extends.apply(null, arguments);
}
/**
* @copyright (C) 2026 Open Source Matters
* @license GNU GPL v2 or later; see LICENSE.txt
*/
Joomla = window.Joomla || {};
(() => {
// --- Constants ---
const STAGE_WIDTH = 200;
const STAGE_HEIGHT = 100;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_SENSITIVITY = 0.1;
const CORNER_RADIUS = 10;
// This central state object holds all data needed for rendering.
const state = {
workflow: null,
stages: [],
transitions: [],
scale: 1,
panX: 0,
panY: 0,
isDraggingStage: false,
highlightedEdge: null
};
// --- Translation ---
const translate = string => {
return Joomla.Text._(string);
};
const sprintf = (string, ...args) => {
const base = Joomla.Text._(string, string);
let i = 0;
return base.replace(/%((%)|s|d)/g, m => {
let val = args[i];
if (m === '%d') {
val = parseFloat(val);
if (Number.isNaN(val)) val = 0;
}
i += 1;
return val;
});
};
// Get full url for marker to avoid issues with base tags
const getMarkerUrl = id => {
const location = window.location.href.split('#')[0];
return `url(${location}#${id})`;
};
function showMessageInModal(message, type) {
const messages = {};
messages[type] = [Joomla.Text._(message)];
Joomla.renderMessages(messages);
{
const dialog = document.querySelector('joomla-dialog');
if (dialog) {
dialog.close();
}
}
}
async function makeRequest(url) {
try {
const paths = Joomla.getOptions('system.paths');
const baseUri = `${paths ? `${paths.baseFull}index.php` : window.location.pathname}`;
const uri = `${baseUri}?option=com_workflow&extension=com_content&layout=modal&view=graph${url}`;
const response = await fetch(uri, {
credentials: 'same-origin'
});
if (!response.ok) {
let message = 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN';
if (response.status === 401) message = 'COM_WORKFLOW_GRAPH_ERROR_NOT_AUTHENTICATED';else if (response.status >= 403) message = 'COM_WORKFLOW_GRAPH_ERROR_NO_PERMISSION';else if (response.status != 200) message = sprintf('COM_WORKFLOW_GRAPH_ERROR_REQUEST_FAILED', response.status);
throw new Error(message);
}
const responseData = await response.json();
if (responseData.success === false) {
throw new Error(responseData.message || 'COM_WORKFLOW_GRAPH_ERROR_API_RETURNED_ERROR');
}
return responseData;
} catch (err) {
showMessageInModal(err.message, "error");
return false;
}
}
// --- Initial Layout Creation ---
function calculateAutoLayout(stages) {
const withNoPosition = stages.filter(stage => !stage.position || isNaN(stage.position.x) || isNaN(stage.position.y));
if (withNoPosition.length === 0) return stages;
const fromAnyStage = stages.find(s => s.id === 'from_any');
const transitionStages = stages.filter(s => s.id !== 'from_any');
const gapX = 400;
const gapY = 300;
const paddingX = 100;
const paddingY = 100;
const columns = Math.min(4, Math.ceil(Math.sqrt(transitionStages.length) + 1));
transitionStages.forEach((stage, index) => {
if (withNoPosition.some(s => s.id === stage.id)) {
const col = index % columns;
const row = Math.floor(index / columns);
stage.position = {
x: col * gapX + paddingX,
y: row * gapY + paddingY
};
}
});
if (fromAnyStage && withNoPosition.some(s => s.id === fromAnyStage.id)) {
if (!fromAnyStage.position || isNaN(fromAnyStage.position.x) || isNaN(fromAnyStage.position.y)) {
fromAnyStage.position = {
x: 600,
y: -200
};
}
}
return stages;
}
function buildPathFromPoints(points) {
let path = `M ${points[0].x} ${points[0].y - 50}`;
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
const dx1 = prev.x - curr.x;
const dy1 = prev.y - curr.y;
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
if (len1 < 1 || len2 < 1) {
path += ` L ${curr.x} ${curr.y}`;
continue;
}
const r = Math.min(CORNER_RADIUS, len1 / 2, len2 / 2);
const startX = curr.x + dx1 / len1 * r;
const startY = curr.y + dy1 / len1 * r;
const endX = curr.x + dx2 / len2 * r;
const endY = curr.y + dy2 / len2 * r;
path += ` L ${startX} ${startY}`;
path += ` Q ${curr.x} ${curr.y} ${endX} ${endY}`;
}
path += ` L ${points[points.length - 1].x} ${points[points.length - 1].y}`;
return path;
}
function getVerticalStepPath(sourceX, sourceY, targetX, targetY, midY) {
const points = [{
x: sourceX,
y: sourceY
}, {
x: sourceX,
y: midY
}, {
x: targetX,
y: midY
}, {
x: targetX,
y: targetY
}];
return [buildPathFromPoints(points), (sourceX + targetX) / 2, midY];
}
function getHorizontalStepPath(sourceX, sourceY, targetX, targetY, midX) {
const startStubY = sourceY + 30;
const endStubY = targetY - 30;
const points = [{
x: sourceX,
y: sourceY
}, {
x: sourceX,
y: startStubY
}, {
x: midX,
y: startStubY
}, {
x: midX,
y: endStubY
}, {
x: targetX,
y: endStubY
}, {
x: targetX,
y: targetY
}];
return [buildPathFromPoints(points), midX, (startStubY + endStubY) / 2];
}
function generateEdges(transitions, stages) {
const stageMap = new Map(stages.map(s => [s.id, s]));
const edgeGroups = {};
// Undirected Grouping to prevent overlaps on bi-directional edges
transitions.forEach(tr => {
const fromId = tr.from_stage_id === -1 ? 'from_any' : tr.from_stage_id;
const toId = tr.to_stage_id;
const s1 = String(fromId);
const s2 = String(toId);
const key = s1 < s2 ? `${s1}|${s2}` : `${s2}|${s1}`;
if (!edgeGroups[key]) edgeGroups[key] = [];
edgeGroups[key].push(tr);
});
Object.values(edgeGroups).forEach(group => group.sort((a, b) => a.id - b.id));
return transitions.flatMap(tr => {
const fromId = tr.from_stage_id === -1 ? 'from_any' : tr.from_stage_id;
const toId = tr.to_stage_id;
const fromStage = stageMap.get(fromId);
const toStage = stageMap.get(toId);
if (!(fromStage != null && fromStage.position) || !(toStage != null && toStage.position)) return [];
const sourceX = fromStage.position.x + STAGE_WIDTH / 2;
const sourceY = fromStage.position.y + STAGE_HEIGHT;
const targetX = toStage.position.x + STAGE_WIDTH / 2;
const targetY = toStage.position.y;
const s1 = String(fromId);
const s2 = String(toId);
const groupKey = s1 < s2 ? `${s1}|${s2}` : `${s2}|${s1}`;
const group = edgeGroups[groupKey] || [tr];
const transitionIndex = group.findIndex(t => t.id === tr.id);
let offsetIndex = transitionIndex - (group.length - 1) / 2;
const bundleSpacing = 40;
let pathData, labelX, labelY;
// Obstruction Check
let isVerticalObstructed = false;
const distX = Math.abs(sourceX - targetX);
const isVerticallyAligned = distX < STAGE_WIDTH;
if (isVerticallyAligned && targetY > sourceY) {
isVerticalObstructed = stages.some(stage => {
if (stage.id === fromId || stage.id === toId) return false;
const sTop = stage.position.y;
const sBottom = stage.position.y + STAGE_HEIGHT;
const isBetweenY = sTop > sourceY && sBottom < targetY;
const sLeft = stage.position.x;
const sRight = stage.position.x + STAGE_WIDTH;
const pathX = sourceX;
const isBlockingX = pathX > sLeft - 20 && pathX < sRight + 20;
return isBetweenY && isBlockingX;
});
}
const isStacked = targetY > sourceY + 50 && !isVerticalObstructed && distX > 40;
if (isStacked) {
let midY = (sourceY + targetY) / 2;
midY += offsetIndex * bundleSpacing;
[pathData, labelX, labelY] = getVerticalStepPath(sourceX, sourceY, targetX, targetY, midY);
labelX += offsetIndex * 60; // Stagger X
} else {
let midX = (sourceX + targetX) / 2;
if (distX < STAGE_WIDTH || isVerticalObstructed) {
midX = Math.max(sourceX, targetX) + STAGE_WIDTH / 2 + 60;
}
midX += offsetIndex * bundleSpacing;
[pathData, labelX, labelY] = getHorizontalStepPath(sourceX, sourceY, targetX, targetY, midX);
labelY += offsetIndex * 35; // Stagger Y
}
return {
id: `transition-${tr.id}`,
pathData,
label: tr.title,
labelPosition: {
x: labelX,
y: labelY
},
fromId,
toId
};
}).filter(Boolean);
}
function renderGraph(modal) {
const graph = modal.querySelector('#graph');
const stageContainer = modal.querySelector('#stages');
const svg = modal.querySelector('#connections');
if (!graph || !stageContainer || !svg) return;
// Render Stages
stageContainer.querySelectorAll('[id^="stage-"]').forEach(el => el.remove());
state.stages.forEach(stage => {
let stageEl = document.createElement('div');
stageEl.id = `stage-${stage.id}`;
stageEl.addEventListener('mousedown', e => {
if (e.button === 0) handleNodeDrag(e, stage);
});
const isVirtual = stage.id === 'from_any';
stageEl.className = `stage ${stage.default ? 'default' : ''} ${isVirtual ? 'virtual' : ''}`;
stageEl.style.left = `${stage.position.x}px`;
stageEl.style.top = `${stage.position.y}px`;
let newHTML = isVirtual ? `<div class="stage-title text-truncate">${stage.title}</div>
<div class="d-flex justify-content-between align-items-center mt-2"><div class="badge bg-info rounded-pill p-1"></div></div>` : `<div class="stage-title text-truncate" title="${stage.title}">${stage.title}</div>
<div class="d-flex align-items-center gap-2">
${stage.description ? `<div class="stage-description text-truncate small">${stage.description}</div>` : ''}
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
${typeof stage.published !== 'undefined' ? `<div class="badge ${stage.published == '1' ? 'bg-success' : 'bg-danger'} rounded-pill p-1">${stage.published == '1' ? translate('COM_WORKFLOW_GRAPH_ENABLED') : translate('COM_WORKFLOW_GRAPH_DISABLED')}</div>` : ''}
${stage.default ? `<div class="badge bg-warning rounded-pill p-1">${translate('COM_WORKFLOW_GRAPH_DEFAULT')}</div>` : ''}
</div>`;
stageEl.innerHTML = newHTML;
stageContainer.appendChild(stageEl);
});
// --- Setting up path SVG layers ---
let pathsLayer = svg.querySelector('g.layers-paths');
let labelsLayer = svg.querySelector('g.layers-labels');
if (!pathsLayer) {
pathsLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
pathsLayer.classList.add('layers-paths');
svg.appendChild(pathsLayer);
}
if (!labelsLayer) {
labelsLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
labelsLayer.classList.add('layers-labels');
svg.appendChild(labelsLayer);
} else {
svg.appendChild(labelsLayer); // Ensure it is last (top)
}
const edges = generateEdges(state.transitions, state.stages);
// Cleanup orphans
pathsLayer.querySelectorAll('path[data-edge-id]').forEach(el => {
if (!edges.find(e => e.id === el.dataset.edgeId)) el.remove();
});
labelsLayer.querySelectorAll('foreignObject[data-edge-id]').forEach(el => {
if (!edges.find(e => e.id === el.dataset.edgeId)) el.remove();
});
edges.forEach(edge => {
let path = pathsLayer.querySelector(`path[data-edge-id="${edge.id}"]`);
if (!path) {
path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.dataset.edgeId = edge.id;
path.setAttribute('class', 'transition-path');
pathsLayer.appendChild(path);
}
path.setAttribute('d', edge.pathData);
path.classList.toggle('highlighted', state.highlightedEdge === edge.id);
path.setAttribute('marker-end', getMarkerUrl('arrowhead'));
let foreignObject = labelsLayer.querySelector(`foreignObject[data-edge-id="${edge.id}"]`);
let labelDiv;
if (!foreignObject) {
foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
foreignObject.dataset.edgeId = edge.id;
foreignObject.style.overflow = 'visible';
labelDiv = document.createElement('div');
labelDiv.className = 'transition-label-content';
labelDiv.addEventListener('click', e => {
e.stopPropagation();
state.highlightedEdge = state.highlightedEdge === edge.id ? null : edge.id;
renderGraph(modal);
});
foreignObject.appendChild(labelDiv);
labelsLayer.appendChild(foreignObject);
} else {
labelDiv = foreignObject.querySelector('div');
}
labelDiv.textContent = edge.label;
labelDiv.classList.toggle('highlighted', state.highlightedEdge === edge.id);
graph.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
requestAnimationFrame(() => {
labelDiv.style.width = 'max-content';
const rect = labelDiv.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const measuredWidth = rect.width / state.scale;
const measuredHeight = rect.height / state.scale || 24;
foreignObject.setAttribute('width', measuredWidth + 4);
foreignObject.setAttribute('height', measuredHeight + 4);
foreignObject.setAttribute('x', edge.labelPosition.x - measuredWidth / 2);
foreignObject.setAttribute('y', edge.labelPosition.y - measuredHeight / 2);
});
});
// Grid Background
const workflowGraph = modal.querySelector('#workflow-graph');
if (workflowGraph) {
const dotSize = Math.max(0.3, Math.min(1, state.scale));
const spacing = 20 * state.scale;
workflowGraph.style.backgroundImage = `
radial-gradient(circle,
color-mix(in srgb, var(--body-color) 80%, transparent) 0px,
color-mix(in srgb, var(--body-color) 60%, transparent) ${dotSize}px,
transparent ${dotSize * 2}px
)
`;
workflowGraph.style.backgroundSize = `${spacing}px ${spacing}px`;
workflowGraph.style.backgroundPosition = `${state.panX}px ${state.panY}px`;
}
}
function handleNodeDrag(startEvent, draggedStage) {
if (draggedStage.id === 'from_any') return;
const stageElement = document.getElementById(`stage-${draggedStage.id}`);
state.isDraggingStage = true;
const dragStart = {
x: startEvent.clientX,
y: startEvent.clientY,
stageX: draggedStage.position.x,
stageY: draggedStage.position.y
};
stageElement.classList.add('dragging');
const onMouseMove = moveEvent => {
const newX = dragStart.stageX + (moveEvent.clientX - dragStart.x) / state.scale;
const newY = dragStart.stageY + (moveEvent.clientY - dragStart.y) / state.scale;
const stageToUpdate = state.stages.find(s => s.id === draggedStage.id);
if (stageToUpdate) {
stageToUpdate.position.x = newX;
stageToUpdate.position.y = newY;
}
renderGraph(document.querySelector('#workflow-graph'));
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
stageElement.classList.remove('dragging');
state.isDraggingStage = false;
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
async function init(modal) {
const container = modal.querySelector('#workflow-graph');
const graph = modal.querySelector('#graph');
if (!container || container.dataset.initialized) return;
container.dataset.initialized = 'true';
const workflowContainer = container.querySelector('#workflow-container');
const workflowId = parseInt(workflowContainer.dataset.workflowId, 10);
if (!workflowId) return showMessageInModal('COM_WORKFLOW_GRAPH_ERROR_INVALID_ID', 'error');
// Standard Arrowhead definition (points right, orient=auto handles the rest)
modal.querySelector('#connections').innerHTML = `<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2071c6" />
</marker>
</defs>`;
try {
const workflowData = await makeRequest(`&task=graph.getWorkflow&workflow_id=${workflowId}&format=json`);
if (!workflowData) return;
const stagesData = await makeRequest(`&task=graph.getStages&workflow_id=${workflowId}&format=json`);
if (!stagesData) return;
const transitionsData = await makeRequest(`&task=graph.getTransitions&workflow_id=${workflowId}&format=json`);
if (!transitionsData) return;
state.workflow = (workflowData == null ? void 0 : workflowData.data) || {};
let stages = (stagesData == null ? void 0 : stagesData.data) || [];
state.transitions = (transitionsData == null ? void 0 : transitionsData.data) || [];
if (!stages.length) return showMessageInModal('COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND', 'error');
if (state.transitions.some(tr => tr.from_stage_id === -1) && !stages.some(s => s.id === 'from_any')) {
stages.unshift({
id: 'from_any',
title: translate('COM_WORKFLOW_GRAPH_FROM_ANY'),
position: null
});
}
state.stages = stages.map(s => _extends({}, s, {
position: s.position || {
x: NaN,
y: NaN
}
}));
state.stages = calculateAutoLayout(state.stages);
// Update UI Counts
modal.querySelector('.joomla-dialog-header h3').textContent = state.workflow.title || translate('COM_WORKFLOW_GRAPH_WORKFLOW');
const statusBadge = modal.querySelector('#workflow-status-badge');
if (statusBadge) {
statusBadge.textContent = state.workflow.published == '1' ? translate('COM_WORKFLOW_GRAPH_ENABLED') : translate('COM_WORKFLOW_GRAPH_DISABLED');
statusBadge.classList.add(state.workflow.published == '1' ? 'bg-success' : 'bg-warning');
}
const realStagesCount = state.stages.filter(s => s.id !== 'from_any').length;
const stageCount = modal.querySelector('#workflow-stage-count');
if (stageCount) stageCount.textContent = `${realStagesCount} ${realStagesCount === 1 ? translate('COM_WORKFLOW_GRAPH_STAGE') : translate('COM_WORKFLOW_GRAPH_STAGES')}`;
const transitionCount = modal.querySelector('#workflow-transition-count');
if (transitionCount) transitionCount.textContent = `${state.transitions.length} ${state.transitions.length === 1 ? translate('COM_WORKFLOW_GRAPH_TRANSITION') : translate('COM_WORKFLOW_GRAPH_TRANSITIONS')}`;
renderGraph(modal);
setTimeout(() => fitToScreen(modal), 150);
} catch (error) {
showMessageInModal(error.message, 'error');
return;
}
// Zoom & Pan Logic
let isPanning = false,
panStart = {};
container.addEventListener("mousedown", e => {
if (e.target.closest('.stage') || e.target.closest('.zoom-controls') || e.button !== 0) return;
isPanning = true;
panStart = {
x: e.clientX - state.panX,
y: e.clientY - state.panY
};
if (graph) graph.classList.add('dragging');
});
document.addEventListener("mousemove", e => {
if (!isPanning) return;
state.panX = e.clientX - panStart.x;
state.panY = e.clientY - panStart.y;
renderGraph(modal);
});
const stopPanning = () => {
isPanning = false;
if (graph) graph.classList.remove('dragging');
};
document.addEventListener("mouseup", stopPanning);
container.addEventListener("mouseleave", stopPanning);
container.addEventListener("wheel", e => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const oldScale = state.scale;
const zoomDirection = e.deltaY < 0 ? 1 : -1;
state.scale = Math.max(MIN_ZOOM, Math.min(state.scale * (1 + zoomDirection * ZOOM_SENSITIVITY), MAX_ZOOM));
const factor = state.scale / oldScale;
state.panX = mouseX - (mouseX - state.panX) * factor;
state.panY = mouseY - (mouseY - state.panY) * factor;
renderGraph(modal);
});
const zoomControls = container.querySelector('.zoom-controls');
zoomControls.querySelector('.zoom-in').addEventListener('click', () => applyZoom(1.2, modal));
zoomControls.querySelector('.zoom-out').addEventListener('click', () => applyZoom(1 / 1.2, modal));
zoomControls.querySelector('.fit-screen').addEventListener('click', () => fitToScreen(modal));
function applyZoom(factor, modalContext) {
const rect = modalContext.querySelector('#workflow-graph').getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const oldScale = state.scale;
state.scale = Math.max(MIN_ZOOM, Math.min(state.scale * factor, MAX_ZOOM));
const scaleRatio = state.scale / oldScale;
state.panX = centerX - (centerX - state.panX) * scaleRatio;
state.panY = centerY - (centerY - state.panY) * scaleRatio;
renderGraph(modalContext);
}
function fitToScreen(modalContext) {
if (!state.stages.length) return;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
state.stages.forEach(s => {
if (s.position) {
minX = Math.min(minX, s.position.x);
minY = Math.min(minY, s.position.y);
maxX = Math.max(maxX, s.position.x + STAGE_WIDTH);
maxY = Math.max(maxY, s.position.y + STAGE_HEIGHT);
}
});
if (minX === Infinity) return;
const containerRect = modalContext.querySelector('#workflow-graph').getBoundingClientRect();
const padding = 50;
const w = maxX - minX;
const h = maxY - minY;
state.scale = Math.max(MIN_ZOOM, Math.min((containerRect.width - padding) / w, (containerRect.height - padding) / h, MAX_ZOOM));
state.panX = (containerRect.width - w * state.scale) / 2 - minX * state.scale;
state.panY = (containerRect.height - h * state.scale) / 2 - minY * state.scale;
renderGraph(modalContext);
}
// --- Keyboard Shortcuts ---
document.addEventListener('keydown', e => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
const PAN_STEP = 30; // Pixels to move
const ZOOM_STEP = 1.1; // Multiplier
switch (e.code) {
/* ---------- Zoom ---------- */
case 'Equal':
// + / =
applyZoom(ZOOM_STEP, modal);
break;
case 'Minus':
// - / _
applyZoom(1 / ZOOM_STEP, modal);
break;
/* ---------- Panning ---------- */
case 'ArrowLeft':
case 'KeyA':
state.panX += PAN_STEP;
renderGraph(modal);
break;
case 'ArrowRight':
case 'KeyD':
state.panX -= PAN_STEP;
renderGraph(modal);
break;
case 'ArrowUp':
case 'KeyW':
state.panY += PAN_STEP;
renderGraph(modal);
break;
case 'ArrowDown':
case 'KeyS':
state.panY -= PAN_STEP;
renderGraph(modal);
break;
/* ---------- Reset / Fit ---------- */
case 'Digit0':
case 'KeyF':
fitToScreen(modal);
break;
}
});
}
document.addEventListener('joomla-dialog:open', event => {
const dialog = event.target;
if (dialog.querySelector('#workflow-container')) init(dialog);
});
})();
@@ -0,0 +1,23 @@
function _extends(){return _extends=Object.assign?Object.assign.bind():function($){for(var M=1;M<arguments.length;M++){var A=arguments[M];for(var b in A)({}).hasOwnProperty.call(A,b)&&($[b]=A[b])}return $},_extends.apply(null,arguments)}/**
* @copyright (C) 2026 Open Source Matters
* @license GNU GPL v2 or later; see LICENSE.txt
*/Joomla=window.Joomla||{},(()=>{const e={workflow:null,stages:[],transitions:[],scale:1,panX:0,panY:0,isDraggingStage:!1,highlightedEdge:null},E=t=>Joomla.Text._(t),H=(t,...a)=>{const l=Joomla.Text._(t,t);let d=0;return l.replace(/%((%)|s|d)/g,n=>{let r=a[d];return n==="%d"&&(r=parseFloat(r),Number.isNaN(r)&&(r=0)),d+=1,r})},G=t=>`url(${window.location.href.split("#")[0]}#${t})`;function v(t,a){const l={};l[a]=[Joomla.Text._(t)],Joomla.renderMessages(l);{const d=document.querySelector("joomla-dialog");d&&d.close()}}async function T(t){try{const a=Joomla.getOptions("system.paths"),d=`${`${a?`${a.baseFull}index.php`:window.location.pathname}`}?option=com_workflow&extension=com_content&layout=modal&view=graph${t}`,n=await fetch(d,{credentials:"same-origin"});if(!n.ok){let u="COM_WORKFLOW_GRAPH_ERROR_UNKNOWN";throw n.status===401?u="COM_WORKFLOW_GRAPH_ERROR_NOT_AUTHENTICATED":n.status>=403?u="COM_WORKFLOW_GRAPH_ERROR_NO_PERMISSION":n.status!=200&&(u=H("COM_WORKFLOW_GRAPH_ERROR_REQUEST_FAILED",n.status)),new Error(u)}const r=await n.json();if(r.success===!1)throw new Error(r.message||"COM_WORKFLOW_GRAPH_ERROR_API_RETURNED_ERROR");return r}catch(a){return v(a.message,"error"),!1}}function D(t){const a=t.filter(i=>!i.position||isNaN(i.position.x)||isNaN(i.position.y));if(a.length===0)return t;const l=t.find(i=>i.id==="from_any"),d=t.filter(i=>i.id!=="from_any"),n=400,r=300,u=100,p=100,o=Math.min(4,Math.ceil(Math.sqrt(d.length)+1));return d.forEach((i,g)=>{if(a.some(s=>s.id===i.id)){const s=g%o,c=Math.floor(g/o);i.position={x:s*n+u,y:c*r+p}}}),l&&a.some(i=>i.id===l.id)&&(!l.position||isNaN(l.position.x)||isNaN(l.position.y))&&(l.position={x:600,y:-200}),t}function W(t){let a=`M ${t[0].x} ${t[0].y-50}`;for(let l=1;l<t.length-1;l++){const d=t[l-1],n=t[l],r=t[l+1],u=d.x-n.x,p=d.y-n.y,o=Math.sqrt(u*u+p*p),i=r.x-n.x,g=r.y-n.y,s=Math.sqrt(i*i+g*g);if(o<1||s<1){a+=` L ${n.x} ${n.y}`;continue}const c=Math.min(10,o/2,s/2),f=n.x+u/o*c,h=n.y+p/o*c,_=n.x+i/s*c,y=n.y+g/s*c;a+=` L ${f} ${h}`,a+=` Q ${n.x} ${n.y} ${_} ${y}`}return a+=` L ${t[t.length-1].x} ${t[t.length-1].y}`,a}function P(t,a,l,d,n){return[W([{x:t,y:a},{x:t,y:n},{x:l,y:n},{x:l,y:d}]),(t+l)/2,n]}function Y(t,a,l,d,n){const r=a+30,u=d-30;return[W([{x:t,y:a},{x:t,y:r},{x:n,y:r},{x:n,y:u},{x:l,y:u},{x:l,y:d}]),n,(r+u)/2]}function q(t,a){const l=new Map(a.map(n=>[n.id,n])),d={};return t.forEach(n=>{const r=n.from_stage_id===-1?"from_any":n.from_stage_id,u=n.to_stage_id,p=String(r),o=String(u),i=p<o?`${p}|${o}`:`${o}|${p}`;d[i]||(d[i]=[]),d[i].push(n)}),Object.values(d).forEach(n=>n.sort((r,u)=>r.id-u.id)),t.flatMap(n=>{const r=n.from_stage_id===-1?"from_any":n.from_stage_id,u=n.to_stage_id,p=l.get(r),o=l.get(u);if(!(p!=null&&p.position)||!(o!=null&&o.position))return[];const i=p.position.x+200/2,g=p.position.y+100,s=o.position.x+200/2,c=o.position.y,f=String(r),h=String(u),_=f<h?`${f}|${h}`:`${h}|${f}`,y=d[_]||[n];let x=y.findIndex(m=>m.id===n.id)-(y.length-1)/2;const w=40;let R,L,I,k=!1;const N=Math.abs(i-s);if(N<200&&c>g&&(k=a.some(m=>{if(m.id===r||m.id===u)return!1;const K=m.position.y,j=m.position.y+100,B=K>g&&j<c,U=m.position.x,z=m.position.x+200,C=i,Z=C>U-20&&C<z+20;return B&&Z})),c>g+50&&!k&&N>40){let m=(g+c)/2;m+=x*w,[R,L,I]=P(i,g,s,c,m),L+=x*60}else{let m=(i+s)/2;(N<200||k)&&(m=Math.max(i,s)+200/2+60),m+=x*w,[R,L,I]=Y(i,g,s,c,m),I+=x*35}return{id:`transition-${n.id}`,pathData:R,label:n.title,labelPosition:{x:L,y:I},fromId:r,toId:u}}).filter(Boolean)}function S(t){const a=t.querySelector("#graph"),l=t.querySelector("#stages"),d=t.querySelector("#connections");if(!a||!l||!d)return;l.querySelectorAll('[id^="stage-"]').forEach(o=>o.remove()),e.stages.forEach(o=>{let i=document.createElement("div");i.id=`stage-${o.id}`,i.addEventListener("mousedown",c=>{c.button===0&&X(c,o)});const g=o.id==="from_any";i.className=`stage ${o.default?"default":""} ${g?"virtual":""}`,i.style.left=`${o.position.x}px`,i.style.top=`${o.position.y}px`;let s=g?`<div class="stage-title text-truncate">${o.title}</div>
<div class="d-flex justify-content-between align-items-center mt-2"><div class="badge bg-info rounded-pill p-1"></div></div>`:`<div class="stage-title text-truncate" title="${o.title}">${o.title}</div>
<div class="d-flex align-items-center gap-2">
${o.description?`<div class="stage-description text-truncate small">${o.description}</div>`:""}
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
${typeof o.published<"u"?`<div class="badge ${o.published=="1"?"bg-success":"bg-danger"} rounded-pill p-1">${o.published=="1"?E("COM_WORKFLOW_GRAPH_ENABLED"):E("COM_WORKFLOW_GRAPH_DISABLED")}</div>`:""}
${o.default?`<div class="badge bg-warning rounded-pill p-1">${E("COM_WORKFLOW_GRAPH_DEFAULT")}</div>`:""}
</div>`;i.innerHTML=s,l.appendChild(i)});let n=d.querySelector("g.layers-paths"),r=d.querySelector("g.layers-labels");n||(n=document.createElementNS("http://www.w3.org/2000/svg","g"),n.classList.add("layers-paths"),d.appendChild(n)),r||(r=document.createElementNS("http://www.w3.org/2000/svg","g"),r.classList.add("layers-labels")),d.appendChild(r);const u=q(e.transitions,e.stages);n.querySelectorAll("path[data-edge-id]").forEach(o=>{u.find(i=>i.id===o.dataset.edgeId)||o.remove()}),r.querySelectorAll("foreignObject[data-edge-id]").forEach(o=>{u.find(i=>i.id===o.dataset.edgeId)||o.remove()}),u.forEach(o=>{let i=n.querySelector(`path[data-edge-id="${o.id}"]`);i||(i=document.createElementNS("http://www.w3.org/2000/svg","path"),i.dataset.edgeId=o.id,i.setAttribute("class","transition-path"),n.appendChild(i)),i.setAttribute("d",o.pathData),i.classList.toggle("highlighted",e.highlightedEdge===o.id),i.setAttribute("marker-end",G("arrowhead"));let g=r.querySelector(`foreignObject[data-edge-id="${o.id}"]`),s;g?s=g.querySelector("div"):(g=document.createElementNS("http://www.w3.org/2000/svg","foreignObject"),g.dataset.edgeId=o.id,g.style.overflow="visible",s=document.createElement("div"),s.className="transition-label-content",s.addEventListener("click",c=>{c.stopPropagation(),e.highlightedEdge=e.highlightedEdge===o.id?null:o.id,S(t)}),g.appendChild(s),r.appendChild(g)),s.textContent=o.label,s.classList.toggle("highlighted",e.highlightedEdge===o.id),a.style.transform=`translate(${e.panX}px, ${e.panY}px) scale(${e.scale})`,requestAnimationFrame(()=>{s.style.width="max-content";const c=s.getBoundingClientRect();if(c.width===0&&c.height===0)return;const f=c.width/e.scale,h=c.height/e.scale||24;g.setAttribute("width",f+4),g.setAttribute("height",h+4),g.setAttribute("x",o.labelPosition.x-f/2),g.setAttribute("y",o.labelPosition.y-h/2)})});const p=t.querySelector("#workflow-graph");if(p){const o=Math.max(.3,Math.min(1,e.scale)),i=20*e.scale;p.style.backgroundImage=`
radial-gradient(circle,
color-mix(in srgb, var(--body-color) 80%, transparent) 0px,
color-mix(in srgb, var(--body-color) 60%, transparent) ${o}px,
transparent ${o*2}px
)
`,p.style.backgroundSize=`${i}px ${i}px`,p.style.backgroundPosition=`${e.panX}px ${e.panY}px`}}function X(t,a){if(a.id==="from_any")return;const l=document.getElementById(`stage-${a.id}`);e.isDraggingStage=!0;const d={x:t.clientX,y:t.clientY,stageX:a.position.x,stageY:a.position.y};l.classList.add("dragging");const n=u=>{const p=d.stageX+(u.clientX-d.x)/e.scale,o=d.stageY+(u.clientY-d.y)/e.scale,i=e.stages.find(g=>g.id===a.id);i&&(i.position.x=p,i.position.y=o),S(document.querySelector("#workflow-graph"))},r=()=>{document.removeEventListener("mousemove",n),document.removeEventListener("mouseup",r),l.classList.remove("dragging"),e.isDraggingStage=!1};document.addEventListener("mousemove",n),document.addEventListener("mouseup",r)}async function F(t){const a=t.querySelector("#workflow-graph"),l=t.querySelector("#graph");if(!a||a.dataset.initialized)return;a.dataset.initialized="true";const d=a.querySelector("#workflow-container"),n=parseInt(d.dataset.workflowId,10);if(!n)return v("COM_WORKFLOW_GRAPH_ERROR_INVALID_ID","error");t.querySelector("#connections").innerHTML=`<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2071c6" />
</marker>
</defs>`;try{const s=await T(`&task=graph.getWorkflow&workflow_id=${n}&format=json`);if(!s)return;const c=await T(`&task=graph.getStages&workflow_id=${n}&format=json`);if(!c)return;const f=await T(`&task=graph.getTransitions&workflow_id=${n}&format=json`);if(!f)return;e.workflow=s?.data||{};let h=c?.data||[];if(e.transitions=f?.data||[],!h.length)return v("COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND","error");e.transitions.some(w=>w.from_stage_id===-1)&&!h.some(w=>w.id==="from_any")&&h.unshift({id:"from_any",title:E("COM_WORKFLOW_GRAPH_FROM_ANY"),position:null}),e.stages=h.map(w=>_extends({},w,{position:w.position||{x:NaN,y:NaN}})),e.stages=D(e.stages),t.querySelector(".joomla-dialog-header h3").textContent=e.workflow.title||E("COM_WORKFLOW_GRAPH_WORKFLOW");const _=t.querySelector("#workflow-status-badge");_&&(_.textContent=e.workflow.published=="1"?E("COM_WORKFLOW_GRAPH_ENABLED"):E("COM_WORKFLOW_GRAPH_DISABLED"),_.classList.add(e.workflow.published=="1"?"bg-success":"bg-warning"));const y=e.stages.filter(w=>w.id!=="from_any").length,O=t.querySelector("#workflow-stage-count");O&&(O.textContent=`${y} ${E(y===1?"COM_WORKFLOW_GRAPH_STAGE":"COM_WORKFLOW_GRAPH_STAGES")}`);const x=t.querySelector("#workflow-transition-count");x&&(x.textContent=`${e.transitions.length} ${e.transitions.length===1?E("COM_WORKFLOW_GRAPH_TRANSITION"):E("COM_WORKFLOW_GRAPH_TRANSITIONS")}`),S(t),setTimeout(()=>g(t),150)}catch(s){v(s.message,"error");return}let r=!1,u={};a.addEventListener("mousedown",s=>{s.target.closest(".stage")||s.target.closest(".zoom-controls")||s.button!==0||(r=!0,u={x:s.clientX-e.panX,y:s.clientY-e.panY},l&&l.classList.add("dragging"))}),document.addEventListener("mousemove",s=>{r&&(e.panX=s.clientX-u.x,e.panY=s.clientY-u.y,S(t))});const p=()=>{r=!1,l&&l.classList.remove("dragging")};document.addEventListener("mouseup",p),a.addEventListener("mouseleave",p),a.addEventListener("wheel",s=>{s.preventDefault();const c=a.getBoundingClientRect(),f=s.clientX-c.left,h=s.clientY-c.top,_=e.scale,y=s.deltaY<0?1:-1;e.scale=Math.max(.5,Math.min(e.scale*(1+y*.1),2));const O=e.scale/_;e.panX=f-(f-e.panX)*O,e.panY=h-(h-e.panY)*O,S(t)});const o=a.querySelector(".zoom-controls");o.querySelector(".zoom-in").addEventListener("click",()=>i(1.2,t)),o.querySelector(".zoom-out").addEventListener("click",()=>i(1/1.2,t)),o.querySelector(".fit-screen").addEventListener("click",()=>g(t));function i(s,c){const f=c.querySelector("#workflow-graph").getBoundingClientRect(),h=f.width/2,_=f.height/2,y=e.scale;e.scale=Math.max(.5,Math.min(e.scale*s,2));const O=e.scale/y;e.panX=h-(h-e.panX)*O,e.panY=_-(_-e.panY)*O,S(c)}function g(s){if(!e.stages.length)return;let c=1/0,f=1/0,h=-1/0,_=-1/0;if(e.stages.forEach(R=>{R.position&&(c=Math.min(c,R.position.x),f=Math.min(f,R.position.y),h=Math.max(h,R.position.x+200),_=Math.max(_,R.position.y+100))}),c===1/0)return;const y=s.querySelector("#workflow-graph").getBoundingClientRect(),O=50,x=h-c,w=_-f;e.scale=Math.max(.5,Math.min((y.width-O)/x,(y.height-O)/w,2)),e.panX=(y.width-x*e.scale)/2-c*e.scale,e.panY=(y.height-w*e.scale)/2-f*e.scale,S(s)}document.addEventListener("keydown",s=>{if(["INPUT","TEXTAREA"].includes(document.activeElement.tagName))return;const c=30,f=1.1;switch(s.code){case"Equal":i(f,t);break;case"Minus":i(1/f,t);break;case"ArrowLeft":case"KeyA":e.panX+=c,S(t);break;case"ArrowRight":case"KeyD":e.panX-=c,S(t);break;case"ArrowUp":case"KeyW":e.panY+=c,S(t);break;case"ArrowDown":case"KeyS":e.panY-=c,S(t);break;case"Digit0":case"KeyF":g(t);break}})}document.addEventListener("joomla-dialog:open",t=>{const a=t.target;a.querySelector("#workflow-container")&&F(a)})})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
/* ================================
Workflow Graph Client Styles
================================ */
/* Color Variables */
:root {
--wf-bg: var(--bg-primary);
--wf-bg: var(--bg-primary);
--wf-border: var(--border-color);
--wf-border-hover: var(--border-color-translucent);
--wf-dot-color: var(--body-color);
--stage-bg: rgb(var(--primary-rgb));
--stage-border: var(--border-color);
--stage-text: var(--white);
--stage-desc: #ffffffc0 !important;
--stage-virtual-bg: #800080;
--transition-hover: var(--border-color-hover);
--transition-label-bg: #2071c6;
--transition-stroke: var(--transition-label-bg);
--transition-label-color: var(--white);
--transition-highlight: #ff6868;
--zoom-btn-bg: transparent;
--zoom-btn-hover: var(--border-color);
--zoom-btn-color: var(--text-primary);
--vf-custom-controls-bgcolor: var(--secondary);
}
@keyframes dashmove {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -36;
}
}
#workflow-graph {
position: relative;
width: 100%;
height: 80vh;
overflow: hidden;
cursor: grab;
background-image: radial-gradient(circle at 1px 1px, var(--wf-dot-color) 1px, transparent 1px);
background-size: 38px 38px;
border: 1px solid var(--stage-border);
border-radius: 8px;
}
#workflow-graph:active {
cursor: grabbing;
}
#workflow-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
#graph {
position: relative;
width: fit-content;
min-width: 100%;
min-height: 100%;
transition: transform .15s ease-out;
transform-origin: 0 0;
}
#graph.dragging {
transition: none;
}
#stages {
position: relative;
width: 100%;
height: 100%;
}
#connections {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
/* ================================
Stage Styling
================================ */
.stage {
position: absolute;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: center;
width: 200px;
min-height: 80px;
padding: 12px 16px;
cursor: move;
user-select: none;
background: var(--stage-bg);
border: 1px solid var(--stage-border);
border-radius: 6px;
box-shadow: 0 4px 10px rgba(0, 0, 0, .6);
}
.stage:hover {
transform: translateY(-1px);
}
.stage.dragging {
z-index: 1000;
transform: rotate(1deg) scale(1.02);
}
.stage.virtual {
background: var(--stage-virtual-bg);
border-width: 1px;
}
.stage-title {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
color: var(--stage-text);
word-wrap: break-word;
}
.stage-description {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.4;
color: var(--stage-desc);
word-wrap: break-word;
}
.stage-badge {
display: inline-block;
align-self: flex-start;
padding: 2px 6px;
font-size: 10px;
font-weight: 500;
color: var(--stage-text);
text-transform: uppercase;
letter-spacing: .5px;
background: var(--stage-border);
border-radius: 3px;
}
/* ================================
Transition Styling
================================ */
.transition-path {
stroke: var(--transition-stroke);
stroke-width: 3px;
fill: none;
opacity: .8;
transition: stroke .2s, stroke-width .2s;
}
.transition-path:hover {
stroke-width: 3px;
opacity: 1;
stroke: var(--transition-hover);
animation-duration: .5s;
}
.arrow-marker {
fill: var(--transition-stroke);
transition: fill .2s ease;
}
.transition-path:hover+.arrow-marker {
fill: var(--transition-hover);
}
.transition-path.highlighted {
stroke: var(--transition-highlight);
stroke-width: 5px;
stroke-dasharray: 12 6;
animation: dashmove .5s linear infinite;
}
.transition-label-content {
position: absolute;
z-index: 20;
min-width: 80px;
max-width: 300px;
padding: 2px 12px;
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
color: var(--transition-label-color);
text-align: center;
white-space: nowrap;
pointer-events: auto;
cursor: pointer;
user-select: none;
background: var(--transition-label-bg) !important;
border: 2px solid var(--transition-stroke);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
transition: background .2s, border .2s, box-shadow .2s;
}
.transition-label-content.highlighted {
font-weight: 700;
border-color: var(--transition-highlight);
transition: transform .2s;
transform: scale(1.1);
}
/* ================================
Zoom Controls
================================ */
.zoom-controls {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
background: var(--vf-custom-controls-bgcolor);
border: 1px solid var(--wf-border);
border-radius: 6px;
backdrop-filter: blur(4px);
}
.zoom-btn:hover {
background: var(--zoom-btn-hover);
transform: scale(1.05);
}
.zoom-btn:active {
transition: transform .5s ease;
transform: scale(.95);
}
@@ -0,0 +1,220 @@
/* ================================
Workflow Graph Styles
================================ */
/* Color Variables */
:root {
--vf-black: #000;
--vf-custom-controls-bgcolor: var(--secondary);
--vf-edge-color: #2071c6;
--vf-white: #fff;
--vf-opacity-less: rgba(0, 0, 0, .1);
--vf-opacity-more: rgba(0, 0, 0, .6);
}
/* ================================
Vue Flow Core Components
================================ */
.vue-flow {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
direction: ltr;
}
[dir="rtl"] .vue-flow__node, [dir="rtl"] .edge-label, [dir="rtl"] .vue-flow__panel {
direction: rtl;
}
.vue-flow__container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.vue-flow__pane.draggable {
cursor: grab;
}
.vue-flow__pane.selection {
cursor: pointer;
}
.vue-flow__pane.dragging {
cursor: grabbing;
}
.vue-flow__transformationpane {
z-index: 2;
pointer-events: none;
transform-origin: 0 0;
}
.vue-flow__viewport {
z-index: 4;
overflow: clip;
}
.vue-flow__nodesselection-rect:focus,
.vue-flow__nodesselection-rect:focus-visible,
.vue-flow__edge.selected,
.vue-flow__edge:focus,
.vue-flow__edge:focus-visible {
outline: none;
}
.vue-flow .vue-flow__edges {
overflow: visible;
pointer-events: none;
}
.vue-flow__edge-path,
.vue-flow__connection-path {
stroke: var(--success);
stroke-width: 3;
fill: none;
}
.vue-flow__edge.animated path {
stroke-dasharray: 5;
animation: dashdraw .5s linear infinite;
}
.vue-flow__edge.animated path.vue-flow__edge-interaction {
stroke-dasharray: none;
animation: none;
}
.vue-flow__edge.inactive {
pointer-events: none;
}
.vue-flow__connection {
pointer-events: none;
}
.vue-flow__connection .animated {
stroke-dasharray: 5;
animation: dashdraw .5s linear infinite;
}
.vue-flow__connectionline {
z-index: 1001;
}
.vue-flow__nodes {
pointer-events: none;
transform-origin: 0 0;
}
.vue-flow__node {
position: absolute;
box-sizing: border-box;
pointer-events: all;
cursor: default;
user-select: none;
transform-origin: 0 0;
}
.vue-flow__handle.connectable {
pointer-events: all;
cursor: crosshair;
}
.vue-flow__panel {
position: absolute;
z-index: 5;
margin: 15px;
}
.vue-flow__panel.top {
top: 0;
}
.vue-flow__panel.bottom {
bottom: 0;
}
.vue-flow__panel.left {
left: 0;
}
.vue-flow__panel.right {
right: 0;
}
.vue-flow__panel.center {
left: 50%;
transform: translateX(-50%);
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
/* ================================
Vue Flow Custom Components
================================ */
.min-vh-80 {
height: 80vh;
}
.end-20-px {
right: 20px !important;
}
.top-25-px {
top: 25px !important;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.custom-edge {
background: var(--vf-edge-color) !important;
}
.vue-flow__minimap {
box-shadow: 0 10px 15px -3px var(--vf-opacity-less);
}
.custom-controls {
right: 10px;
bottom: 10px;
background: var(--vf-custom-controls-bgcolor);
}
.vue-flow__minimap.pannable {
cursor: grab;
}
.stage-node .edge-handler {
width: 12px !important;
height: 12px !important;
}
.stage-node:hover .vue-flow__handle {
opacity: 1;
}
.vue-flow__node-stage {
max-width: 250px !important;
}
.workflow-browser-actions-list {
background-color: var(--vf-black);
box-shadow: 0 4px 10px var(--vf-opacity-more);
}