Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DoodleBUGS/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions DoodleBUGS/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"cytoscape-no-overlap": "^1.0.1",
"cytoscape-snap-to-grid": "^2.0.0",
"cytoscape-svg": "^0.4.0",
"cytoscape-undo-redo": "^1.3.3",
"pinia": "^3.0.2",
"vue": "^3.5.13"
},
Expand Down
14 changes: 14 additions & 0 deletions DoodleBUGS/src/components/canvas/CanvasToolbar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BaseButton from '../ui/BaseButton.vue';
import UndoRedoControls from '../ui/UndoRedoControls.vue';
import type { NodeType } from '../../types';
import { nodeDefinitions } from '../../config/nodeDefinitions';
import { computed } from 'vue';
Expand Down Expand Up @@ -55,6 +56,12 @@ const updateNodeType = (event: Event) => {
Add Edge
</BaseButton>

<!-- Separator -->
<div class="toolbar-separator"></div>

<!-- Undo/Redo Controls -->
<UndoRedoControls />

<div v-if="currentMode === 'add-node'" class="node-type-selector">
<label for="node-type">Node Type:</label>
<select id="node-type" :value="currentNodeType" @change="updateNodeType">
Expand Down Expand Up @@ -129,4 +136,11 @@ const updateNodeType = (event: Event) => {
font-size: 0.9em;
white-space: nowrap;
}

.toolbar-separator {
width: 1px;
height: 24px;
background-color: var(--color-border-dark);
margin: 0 8px;
}
</style>
28 changes: 28 additions & 0 deletions DoodleBUGS/src/components/canvas/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import { useGraphInstance } from '../../composables/useGraphInstance';
import { useGridSnapping } from '../../composables/useGridSnapping';
import type { GraphElement, GraphNode, GraphEdge, NodeType, PaletteItemType, ValidationError } from '../../types';

// Assume nodes is your reactive array of node positions
// You may already have something like this:

const nodes = ref<GraphNode[]>([]);

// Save layout to localStorage whenever nodes change
watch(nodes, (newVal) => {
const layout = newVal.map(node => ({
id: node.id,
x: node.x,
y: node.y,
}))
localStorage.setItem('doodlebugs-layout', JSON.stringify(layout))
}, { deep: true })


const props = defineProps<{
elements: GraphElement[];
isGridEnabled: boolean;
Expand Down Expand Up @@ -102,6 +118,18 @@ const syncGraphWithProps = (elementsToSync: GraphElement[], errorsToSync: Map<st


onMounted(() => {
const savedLayout = localStorage.getItem('doodlebugs-layout');
if (savedLayout && Array.isArray(nodes.value)) {
const layout = JSON.parse(savedLayout);
layout.forEach((savedNode: any) => {
const node = nodes.value.find((n: any) => n.id === savedNode.id);
if (node) {
node.x = savedNode.x;
node.y = savedNode.y;
}
});
}

if (cyContainer.value) {
cy = initCytoscape(cyContainer.value, []);

Expand Down
48 changes: 11 additions & 37 deletions DoodleBUGS/src/components/layouts/MainLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,8 @@ import { useDataStore } from '../../stores/dataStore';
import { useExecutionStore } from '../../stores/executionStore';
import { useGraphInstance } from '../../composables/useGraphInstance';
import { useGraphValidator } from '../../composables/useGraphValidator';
import { useBugsCodeGenerator, generateStandaloneScript } from '../../composables/useBugsCodeGenerator';
import type { GraphElement, NodeType, PaletteItemType, ExampleModel } from '../../types';
import type { ExecutionResult, GeneratedFile } from '../../stores/executionStore';

interface ExportOptions {
bg: string;
full: boolean;
scale: number;
quality?: number;
maxWidth?: number;
maxHeight?: number;
}
import { useUndoRedo } from '../../composables/useUndoRedo';
import type { GraphElement, NodeType, PaletteItemType, GraphNode, ExampleModel } from '../../types';

const projectStore = useProjectStore();
const graphStore = useGraphStore();
Expand All @@ -52,8 +42,7 @@ const { elements, selectedElement, updateElement, deleteElement } = useGraphElem
const { generatedCode } = useBugsCodeGenerator(elements);
const { getCyInstance } = useGraphInstance();
const { validateGraph, validationErrors } = useGraphValidator(elements, parsedGraphData);
const { backendUrl, isConnected, isConnecting, isExecuting, samplerSettings } = storeToRefs(executionStore);
const { activeLeftTab, isLeftSidebarOpen, leftSidebarWidth, isRightSidebarOpen, rightSidebarWidth } = storeToRefs(uiStore);
const { handleKeyboardShortcuts, updateUndoRedoState } = useUndoRedo();

const currentMode = ref<string>('select');
const currentNodeType = ref<NodeType>('stochastic');
Expand Down Expand Up @@ -97,29 +86,14 @@ onMounted(async () => {
}

validateGraph();

// Attempt silent reconnect to saved backend after reloads
if (backendUrl.value) {
try {
const resp = await fetch(`${backendUrl.value}/api/health`);
if (resp.ok) {
const j = await resp.json().catch(() => ({}));
if (j && j.status === 'ok') {
isConnected.value = true;
executionStore.executionLogs.push(`Reconnected to backend at ${backendUrl.value}.`);
} else {
isConnected.value = false;
executionStore.executionLogs.push(`Saved backend URL present but health check returned invalid payload.`);
}
} else {
isConnected.value = false;
executionStore.executionLogs.push(`Saved backend URL present but health check failed with ${resp.status}.`);
}
} catch (e) {
isConnected.value = false;
executionStore.executionLogs.push(`Saved backend URL present but health check errored: ${(e as Error).message}`);
}
}

// Add keyboard shortcuts for undo/redo
document.addEventListener('keydown', handleKeyboardShortcuts);
});

onUnmounted(() => {
// Remove keyboard event listener
document.removeEventListener('keydown', handleKeyboardShortcuts);
});

watch(
Expand Down
105 changes: 105 additions & 0 deletions DoodleBUGS/src/components/ui/UndoRedoControls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<template>
<div class="undo-redo-controls">
<button
class="undo-btn toolbar-btn"
:disabled="!canUndo"
@click="handleUndo"
title="Undo (Ctrl+Z)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6"/>
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/>
</svg>
</button>

<button
class="redo-btn toolbar-btn"
:disabled="!canRedo"
@click="handleRedo"
title="Redo (Ctrl+Shift+Z)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 7v6h-6"/>
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3L21 13"/>
</svg>
</button>
</div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useUndoRedo } from '../../composables/useUndoRedo';

const { canUndo, canRedo, performUndo, performRedo, updateUndoRedoState } = useUndoRedo();

let interval: number;

// Update state when component mounts
onMounted(() => {
updateUndoRedoState();

// Set up periodic state updates to ensure buttons stay in sync
interval = setInterval(updateUndoRedoState, 500);
});

onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});

const handleUndo = () => {
console.log('Undo button clicked');
const result = performUndo();
console.log(`Undo button result: ${result}`);
};

const handleRedo = () => {
console.log('Redo button clicked');
const result = performRedo();
console.log(`Redo button result: ${result}`);
};
</script>

<style scoped>
.undo-redo-controls {
display: flex;
gap: 4px;
align-items: center;
}

.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
min-width: 32px;
min-height: 32px;
}

.toolbar-btn:hover:not(:disabled) {
background: #f5f5f5;
border-color: #999;
}

.toolbar-btn:active:not(:disabled) {
background: #e5e5e5;
}

.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f9f9f9;
}

.toolbar-btn svg {
width: 16px;
height: 16px;
}
</style>
63 changes: 61 additions & 2 deletions DoodleBUGS/src/composables/useGraphElements.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { computed, ref } from 'vue';
import { useGraphStore } from '../stores/graphStore';
import type { GraphElement } from '../types';
import { useGraphInstance } from './useGraphInstance';
import type { GraphElement, GraphNode } from '../types';

export function useGraphElements() {
const graphStore = useGraphStore();
const { getCyInstance, getUndoRedoInstance } = useGraphInstance();

const selectedElement = ref<GraphElement | null>(null);

Expand All @@ -17,8 +19,32 @@ export function useGraphElements() {
});

const addElement = (newElement: GraphElement) => {
// Add to Vue store first for immediate UI update
elements.value = [...elements.value, newElement];
selectedElement.value = newElement;

// Add to cytoscape - let cytoscape-undo-redo handle the tracking
const cy = getCyInstance();
const ur = getUndoRedoInstance();

if (cy) {
try {
const cyElement = cy.add({
group: newElement.type === 'node' ? 'nodes' : 'edges',
data: newElement,
position: newElement.type === 'node' ? (newElement as GraphNode).position : undefined
});

console.log('Element added to cytoscape:', newElement.id);

// Manually trigger undo-redo tracking if needed
if (ur) {
console.log('Undo-redo instance available for tracking');
}
} catch (error) {
console.warn('Failed to add element to cytoscape:', error);
}
}
};

const updateElement = (updatedElement: GraphElement) => {
Expand All @@ -30,7 +56,40 @@ export function useGraphElements() {
}
};

const deleteElement = (elementId: string) => {
const deleteElement = (elementId: string, visited = new Set<string>()) => {
const elementToDelete = elements.value.find(el => el.id === elementId);
if (!elementToDelete) return;

// Remove from Vue store for immediate UI update
deleteElementManually(elementId, visited);

// Remove from cytoscape - let cytoscape-undo-redo handle the tracking
const cy = getCyInstance();
const ur = getUndoRedoInstance();

if (cy) {
try {
const element = cy.getElementById(elementId);
if (element.length > 0) {
element.remove();
console.log('Element removed from cytoscape:', elementId);

if (ur) {
console.log('Undo-redo instance available for tracking removal');
}
}
} catch (error) {
console.warn('Failed to remove element from cytoscape:', error);
}
}
};

const deleteElementManually = (elementId: string, visited = new Set<string>()) => {
if (visited.has(elementId)) {
return;
}
visited.add(elementId);

const elementToDelete = elements.value.find(el => el.id === elementId);
if (!elementToDelete) return;

Expand Down
Loading