Skip to content

Commit 3eb45fb

Browse files
committed
Pan/zoom canvas, section headers in form builder (v0.17.1)
Workflow builder: - Pan canvas with Shift/Ctrl/Cmd+drag or middle-click drag - Zoom with mouse wheel (25%-200%), centres on cursor - Zoom controls in toolbar (in/out/reset + percentage indicator) - Node drag and connection drawing adjusted for zoom level - Canvas overflow hidden for proper viewport clipping Form builder: - Section headers render as distinct visual dividers with indigo accent styling instead of looking like regular fields - Sortable drag handles updated to include section elements - Width badge shown on fields with non-full width
1 parent ba0700a commit 3eb45fb

5 files changed

Lines changed: 229 additions & 34 deletions

File tree

django_forms_workflows/static/django_forms_workflows/js/form-builder.js

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class FormBuilder {
113113
ghostClass: 'sortable-ghost',
114114
dragClass: 'sortable-drag',
115115
handle: '.field-drag-handle',
116-
draggable: '.canvas-field', // Only canvas-field elements are draggable/sortable
116+
draggable: '.field-item', // Both .canvas-field and .canvas-section elements
117117
filter: '.canvas-drop-zone', // Exclude drop zone from sorting
118118
onStart: (evt) => {
119119
// Add dragging class for enhanced visual feedback
@@ -415,32 +415,57 @@ class FormBuilder {
415415

416416
createFieldElement(field, index) {
417417
const div = document.createElement('div');
418-
div.className = 'canvas-field field-item';
419418
div.dataset.index = index;
420419
div.dataset.fieldIndex = index;
421420

422-
const requiredBadge = field.required ? '<span class="badge bg-danger ms-1" style="font-size: 0.65rem; padding: 0.15rem 0.35rem;">REQ</span>' : '';
423-
const fieldInfo = `<span class="text-muted" style="font-size: 0.75rem;">${field.field_name}</span>`;
424-
425-
div.innerHTML = `
426-
<div class="field-header field-drag-handle" style="cursor: move;">
427-
<div>
428-
<i class="bi bi-grip-vertical me-2 text-muted"></i>
429-
<span class="field-label">${this.escapeHtml(field.field_label)}</span>
430-
${requiredBadge}
431-
<span class="ms-2">${fieldInfo}</span>
421+
if (field.field_type === 'section') {
422+
// Section header — render as a prominent divider
423+
div.className = 'canvas-section field-item';
424+
div.innerHTML = `
425+
<div class="field-header field-drag-handle" style="cursor: move;">
426+
<div>
427+
<i class="bi bi-grip-vertical me-2 text-muted"></i>
428+
<i class="bi bi-layout-text-sidebar me-1"></i>
429+
<span class="field-label">${this.escapeHtml(field.field_label)}</span>
430+
</div>
431+
<div class="field-actions">
432+
<span class="field-type-badge section-badge">section</span>
433+
<button class="btn btn-sm btn-outline-primary btn-field-action" onclick="formBuilder.editField(${index})" title="Edit section">
434+
<i class="bi bi-pencil"></i>
435+
</button>
436+
<button class="btn btn-sm btn-outline-danger btn-field-action" onclick="formBuilder.deleteField(${index})" title="Delete section">
437+
<i class="bi bi-trash"></i>
438+
</button>
439+
</div>
432440
</div>
433-
<div class="field-actions">
434-
<span class="field-type-badge">${field.field_type}</span>
435-
<button class="btn btn-sm btn-outline-primary btn-field-action" onclick="formBuilder.editField(${index})" title="Edit field">
436-
<i class="bi bi-pencil"></i>
437-
</button>
438-
<button class="btn btn-sm btn-outline-danger btn-field-action" onclick="formBuilder.deleteField(${index})" title="Delete field">
439-
<i class="bi bi-trash"></i>
440-
</button>
441+
`;
442+
} else {
443+
// Regular field
444+
div.className = 'canvas-field field-item';
445+
const requiredBadge = field.required ? '<span class="badge bg-danger ms-1" style="font-size: 0.65rem; padding: 0.15rem 0.35rem;">REQ</span>' : '';
446+
const fieldInfo = `<span class="text-muted" style="font-size: 0.75rem;">${field.field_name}</span>`;
447+
const widthBadge = field.width && field.width !== 'full' ? `<span class="badge bg-secondary ms-1" style="font-size: 0.6rem;">${field.width}</span>` : '';
448+
449+
div.innerHTML = `
450+
<div class="field-header field-drag-handle" style="cursor: move;">
451+
<div>
452+
<i class="bi bi-grip-vertical me-2 text-muted"></i>
453+
<span class="field-label">${this.escapeHtml(field.field_label)}</span>
454+
${requiredBadge}${widthBadge}
455+
<span class="ms-2">${fieldInfo}</span>
456+
</div>
457+
<div class="field-actions">
458+
<span class="field-type-badge">${field.field_type}</span>
459+
<button class="btn btn-sm btn-outline-primary btn-field-action" onclick="formBuilder.editField(${index})" title="Edit field">
460+
<i class="bi bi-pencil"></i>
461+
</button>
462+
<button class="btn btn-sm btn-outline-danger btn-field-action" onclick="formBuilder.deleteField(${index})" title="Delete field">
463+
<i class="bi bi-trash"></i>
464+
</button>
465+
</div>
441466
</div>
442-
</div>
443-
`;
467+
`;
468+
}
444469

445470
// Add context menu handler for multi-step mode
446471
div.addEventListener('contextmenu', (e) => {

django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class WorkflowBuilder {
2020
this.groups = [];
2121
this.forms = [];
2222

23+
// Pan & zoom state
24+
this.panX = 0;
25+
this.panY = 0;
26+
this.zoom = 1;
27+
this.isPanning = false;
28+
this.minZoom = 0.25;
29+
this.maxZoom = 2;
30+
2331
this.init();
2432
}
2533

@@ -57,19 +65,120 @@ class WorkflowBuilder {
5765
e.preventDefault();
5866
const nodeType = e.dataTransfer.getData('nodeType');
5967
if (nodeType) {
60-
const rect = this.canvas.getBoundingClientRect();
61-
const x = e.clientX - rect.left + this.canvas.scrollLeft;
62-
const y = e.clientY - rect.top + this.canvas.scrollTop;
68+
const [x, y] = this.clientToCanvas(e.clientX, e.clientY);
6369
this.createNode(nodeType, x, y);
6470
}
6571
});
6672

67-
// Click on canvas to deselect
73+
// Click on canvas background to deselect
6874
this.canvas.addEventListener('click', (e) => {
69-
if (e.target === this.canvas) {
75+
if (e.target === this.canvas || e.target === this.svg) {
7076
this.deselectAll();
7177
}
7278
});
79+
80+
// ── Pan (middle-click or Ctrl+left-click on background) ─────────
81+
this.canvas.addEventListener('mousedown', (e) => {
82+
const isBackground = e.target === this.canvas || e.target === this.svg
83+
|| e.target.tagName === 'svg' || e.target.closest('.connections-svg');
84+
const shouldPan = isBackground && (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey)));
85+
if (!shouldPan) return;
86+
87+
e.preventDefault();
88+
this.isPanning = true;
89+
this.canvas.style.cursor = 'grabbing';
90+
const startX = e.clientX, startY = e.clientY;
91+
const startPanX = this.panX, startPanY = this.panY;
92+
93+
const onMove = (ev) => {
94+
this.panX = startPanX + (ev.clientX - startX);
95+
this.panY = startPanY + (ev.clientY - startY);
96+
this.applyTransform();
97+
};
98+
const onUp = () => {
99+
this.isPanning = false;
100+
this.canvas.style.cursor = '';
101+
document.removeEventListener('mousemove', onMove);
102+
document.removeEventListener('mouseup', onUp);
103+
};
104+
document.addEventListener('mousemove', onMove);
105+
document.addEventListener('mouseup', onUp);
106+
});
107+
108+
// Prevent default middle-click scroll
109+
this.canvas.addEventListener('auxclick', (e) => { if (e.button === 1) e.preventDefault(); });
110+
111+
// ── Zoom (mouse wheel) ──────────────────────────────────────────
112+
this.canvas.addEventListener('wheel', (e) => {
113+
e.preventDefault();
114+
const rect = this.canvas.getBoundingClientRect();
115+
// Cursor position relative to canvas element
116+
const cx = e.clientX - rect.left;
117+
const cy = e.clientY - rect.top;
118+
119+
const oldZoom = this.zoom;
120+
const delta = e.deltaY > 0 ? -0.1 : 0.1;
121+
this.zoom = Math.min(this.maxZoom, Math.max(this.minZoom, this.zoom + delta));
122+
123+
// Adjust pan so zoom centres on cursor
124+
const scale = this.zoom / oldZoom;
125+
this.panX = cx - scale * (cx - this.panX);
126+
this.panY = cy - scale * (cy - this.panY);
127+
128+
this.applyTransform();
129+
this.updateZoomIndicator();
130+
}, { passive: false });
131+
}
132+
133+
/** Convert client (screen) coordinates to canvas (node) coordinates. */
134+
clientToCanvas(clientX, clientY) {
135+
const rect = this.canvas.getBoundingClientRect();
136+
const x = (clientX - rect.left - this.panX) / this.zoom;
137+
const y = (clientY - rect.top - this.panY) / this.zoom;
138+
return [x, y];
139+
}
140+
141+
/** Apply the current pan/zoom transform to nodes and SVG. */
142+
applyTransform() {
143+
const t = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
144+
// Apply to every node
145+
this.canvas.querySelectorAll('.workflow-node').forEach(el => {
146+
el.style.transformOrigin = '0 0';
147+
el.style.transform = t;
148+
});
149+
// Apply to SVG connections
150+
if (this.svg) {
151+
this.svg.style.transformOrigin = '0 0';
152+
this.svg.style.transform = t;
153+
}
154+
}
155+
156+
/** Update the zoom percentage indicator. */
157+
updateZoomIndicator() {
158+
const el = document.getElementById('zoomLevel');
159+
if (el) el.textContent = `${Math.round(this.zoom * 100)}%`;
160+
}
161+
162+
/** Zoom to a specific level, centred on the canvas midpoint. */
163+
setZoom(newZoom) {
164+
const rect = this.canvas.getBoundingClientRect();
165+
const cx = rect.width / 2, cy = rect.height / 2;
166+
const oldZoom = this.zoom;
167+
this.zoom = Math.min(this.maxZoom, Math.max(this.minZoom, newZoom));
168+
const scale = this.zoom / oldZoom;
169+
this.panX = cx - scale * (cx - this.panX);
170+
this.panY = cy - scale * (cy - this.panY);
171+
this.applyTransform();
172+
this.updateZoomIndicator();
173+
}
174+
175+
/** Reset pan/zoom to default. */
176+
resetView() {
177+
this.panX = 0;
178+
this.panY = 0;
179+
this.zoom = 1;
180+
this.applyTransform();
181+
this.updateZoomIndicator();
73182
}
74183

75184
setupPalette() {
@@ -1087,6 +1196,7 @@ class WorkflowBuilder {
10871196
console.log('Rendering workflow with', this.nodes.length, 'nodes and', this.connections.length, 'connections');
10881197
this.renderNodes();
10891198
this.renderConnections();
1199+
this.applyTransform();
10901200
}
10911201

10921202
renderNodes() {
@@ -1308,8 +1418,9 @@ class WorkflowBuilder {
13081418
const nodeStartY = node.y;
13091419

13101420
const onMouseMove = (e) => {
1311-
const dx = e.clientX - startX;
1312-
const dy = e.clientY - startY;
1421+
// Divide by zoom so node movement matches cursor speed
1422+
const dx = (e.clientX - startX) / this.zoom;
1423+
const dy = (e.clientY - startY) / this.zoom;
13131424
node.x = nodeStartX + dx;
13141425
node.y = nodeStartY + dy;
13151426
this.render();
@@ -1356,9 +1467,7 @@ class WorkflowBuilder {
13561467
}
13571468

13581469
updateTempConnection(e) {
1359-
const rect = this.canvas.getBoundingClientRect();
1360-
const x = e.clientX - rect.left + this.canvas.scrollLeft;
1361-
const y = e.clientY - rect.top + this.canvas.scrollTop;
1470+
const [x, y] = this.clientToCanvas(e.clientX, e.clientY);
13621471

13631472
const startNode = this.nodes.find(n => n.id === this.connectionStart.nodeId);
13641473
if (!startNode) return;

django_forms_workflows/templates/admin/django_forms_workflows/form_builder.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,30 @@
110110
background: #f8f9fa;
111111
}
112112

113+
/* Section header divider */
114+
.canvas-section {
115+
padding: 0.6rem 0.75rem;
116+
margin-top: 1rem;
117+
margin-bottom: 0.25rem;
118+
background: linear-gradient(135deg, #f0f2ff 0%, #e9ecff 100%);
119+
border: 1px solid #c5cae9;
120+
border-left: 4px solid #5c6bc0;
121+
border-radius: 6px;
122+
position: relative;
123+
transition: all 0.2s;
124+
user-select: none;
125+
touch-action: none;
126+
}
127+
.canvas-section:first-child { margin-top: 0; }
128+
.canvas-section .field-label { font-weight: 600; color: #3949ab; font-size: 0.95rem; }
129+
.canvas-section:hover {
130+
border-left-color: #3949ab;
131+
box-shadow: 0 2px 6px rgba(92, 107, 192, 0.2);
132+
}
133+
.section-badge { background: #5c6bc0 !important; color: white !important; }
134+
113135
/* The ghost placeholder where item will be dropped */
136+
.canvas-section.sortable-ghost,
114137
.canvas-field.sortable-ghost {
115138
opacity: 1;
116139
background: linear-gradient(135deg, #e9ecff 0%, #f0f2ff 100%);
@@ -120,11 +143,13 @@
120143
animation: pulse-drop-zone 1.5s ease-in-out infinite;
121144
}
122145

146+
.canvas-section.sortable-ghost *,
123147
.canvas-field.sortable-ghost * {
124148
opacity: 0.3;
125149
}
126150

127151
/* The item being dragged */
152+
.canvas-section.sortable-drag,
128153
.canvas-field.sortable-drag {
129154
opacity: 0.9;
130155
transform: rotate(2deg) scale(1.05);

django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
height: calc(100% - 60px);
9191
min-height: 600px;
9292
position: relative;
93+
overflow: hidden;
9394
background-image:
9495
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
9596
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px);
@@ -389,7 +390,30 @@
389390
.toolbar-btn:hover {
390391
background: rgba(255,255,255,0.3);
391392
}
392-
393+
394+
.toolbar-btn-sm {
395+
padding: 0.3rem 0.5rem;
396+
font-size: 0.85rem;
397+
}
398+
399+
.zoom-controls {
400+
display: flex;
401+
align-items: center;
402+
gap: 0.25rem;
403+
margin-right: 0.75rem;
404+
background: rgba(0,0,0,0.15);
405+
border-radius: 6px;
406+
padding: 0.15rem 0.35rem;
407+
}
408+
409+
.zoom-level {
410+
color: rgba(255,255,255,0.9);
411+
font-size: 0.8rem;
412+
min-width: 3rem;
413+
text-align: center;
414+
font-weight: 500;
415+
}
416+
393417
.save-status {
394418
color: rgba(255,255,255,0.8);
395419
font-size: 0.875rem;
@@ -456,6 +480,18 @@ <h5 class="mb-0">{{ form_definition.name }} - Workflow</h5>
456480
</small>
457481
</div>
458482
<div class="workflow-toolbar">
483+
<div class="zoom-controls">
484+
<button class="toolbar-btn toolbar-btn-sm" onclick="workflowBuilder.setZoom(workflowBuilder.zoom - 0.1)" title="Zoom out">
485+
<i class="bi bi-dash-lg"></i>
486+
</button>
487+
<span class="zoom-level" id="zoomLevel">100%</span>
488+
<button class="toolbar-btn toolbar-btn-sm" onclick="workflowBuilder.setZoom(workflowBuilder.zoom + 0.1)" title="Zoom in">
489+
<i class="bi bi-plus-lg"></i>
490+
</button>
491+
<button class="toolbar-btn toolbar-btn-sm" onclick="workflowBuilder.resetView()" title="Reset view">
492+
<i class="bi bi-arrows-fullscreen"></i>
493+
</button>
494+
</div>
459495
<span class="save-status" id="saveStatus">Ready</span>
460496
<button class="toolbar-btn" id="btnSave">
461497
<i class="bi bi-save"></i> Save Workflow

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-forms-workflows"
3-
version = "0.17.0"
3+
version = "0.17.1"
44
description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration"
55
license = "LGPL-3.0-only"
66
readme = "README.md"

0 commit comments

Comments
 (0)