Skip to content
Merged
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
11 changes: 10 additions & 1 deletion package-lock.json

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

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
"start:both": "concurrently \"npm run dev\" \"npm run start:backend\""
},
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@uiw/codemirror-theme-vscode": "^4.24.2",
"@uiw/react-codemirror": "^4.24.2",
"@xyflow/react": "^12.8.1",
"lz-string": "^1.5.0",
"plotly.js": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-plotly.js": "^2.6.0",
"@uiw/react-codemirror": "^4.24.2",
"@uiw/codemirror-theme-vscode": "^4.24.2",
"@codemirror/lang-python": "^6.2.1"
"react-plotly.js": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
Expand All @@ -33,4 +34,4 @@
"globals": "^16.0.0",
"vite": "^6.3.5"
}
}
}
19 changes: 14 additions & 5 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// * Imports *
import { useState, useCallback, useEffect, useRef, version } from 'react';

Check failure on line 2 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

'version' is defined but never used. Allowed unused vars must match /^[A-Z_]/u

Check failure on line 2 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

'version' is defined but never used. Allowed unused vars must match /^[A-Z_]/u
import {
ReactFlowProvider,
useReactFlow,
Expand Down Expand Up @@ -90,7 +90,7 @@
}, []);

const onDragStart = (event, nodeType) => {
setType(nodeType);

Check failure on line 93 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

'setType' is not defined

Check failure on line 93 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

'setType' is not defined
event.dataTransfer.setData('text/plain', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
Expand All @@ -107,9 +107,10 @@
const [pythonCode, setPythonCode] = useState("# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n");

// State for URL sharing feedback
const [shareUrlFeedback, setShareUrlFeedback] = useState('');

Check failure on line 110 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

'shareUrlFeedback' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u

Check failure on line 110 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

'shareUrlFeedback' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
const [showShareModal, setShowShareModal] = useState(false);
const [shareableURL, setShareableURL] = useState('');
const [urlMetadata, setUrlMetadata] = useState(null);

// Load graph data from URL on component mount
useEffect(() => {
Expand Down Expand Up @@ -160,7 +161,7 @@
};

loadGraphFromURL();
}, []); // Empty dependency array means this runs once on mount

Check warning on line 164 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

React Hook useEffect has missing dependencies: 'setEdges' and 'setNodes'. Either include them or remove the dependency array

Check warning on line 164 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

React Hook useEffect has missing dependencies: 'setEdges' and 'setNodes'. Either include them or remove the dependency array

const [defaultValues, setDefaultValues] = useState({});
const [isEditingLabel, setIsEditingLabel] = useState(false);
Expand Down Expand Up @@ -365,7 +366,7 @@
setNodes((nds) => [...nds, newNode]);
setNodeCounter((count) => count + 1);
},
[screenToFlowPosition, type, nodeCounter, fetchDefaultValues, setDefaultValues, setNodes, setNodeCounter],

Check warning on line 369 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

React Hook useCallback has an unnecessary dependency: 'setDefaultValues'. Either exclude it or remove the dependency array

Check warning on line 369 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

React Hook useCallback has an unnecessary dependency: 'setDefaultValues'. Either exclude it or remove the dependency array
);

// Function to save a graph to computer with "Save As" dialog
Expand Down Expand Up @@ -581,12 +582,19 @@
};

try {
const url = generateShareableURL(graphData);
if (url) {
setShareableURL(url);
const urlResult = generateShareableURL(graphData);
if (urlResult) {
setShareableURL(urlResult.url);
setUrlMetadata({
length: urlResult.length,
isSafe: urlResult.isSafe,
maxLength: urlResult.maxLength
});
setShowShareModal(true);
// Update browser URL as well
updateURLWithGraphData(graphData, true);
// Only update browser URL if it's safe length
if (urlResult.isSafe) {
updateURLWithGraphData(graphData, true);
}
} else {
setShareUrlFeedback('Error generating share URL');
setTimeout(() => setShareUrlFeedback(''), 3000);
Expand Down Expand Up @@ -1044,7 +1052,7 @@
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectedEdge, selectedNode, copiedNode, duplicateNode, setCopyFeedback]);

Check warning on line 1055 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

React Hook useEffect has missing dependencies: 'deleteSelectedEdge' and 'deleteSelectedNode'. Either include them or remove the dependency array

Check warning on line 1055 in src/App.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

React Hook useEffect has missing dependencies: 'deleteSelectedEdge' and 'deleteSelectedNode'. Either include them or remove the dependency array

return (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column' }}>
Expand Down Expand Up @@ -1219,6 +1227,7 @@
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
shareableURL={shareableURL}
urlMetadata={urlMetadata}
/>

</div>
Expand Down
28 changes: 26 additions & 2 deletions src/components/ShareModal.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react';

const ShareModal = ({ isOpen, onClose, shareableURL }) => {
const ShareModal = ({ isOpen, onClose, shareableURL, urlMetadata }) => {
const [copyFeedback, setCopyFeedback] = useState('');

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareableURL);
setCopyFeedback('Copied!');
} catch (error) {

Check failure on line 10 in src/components/ShareModal.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

'error' is defined but never used

Check failure on line 10 in src/components/ShareModal.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

'error' is defined but never used
// Fallback for older browsers
try {
const textArea = document.createElement('textarea');
Expand All @@ -17,7 +17,7 @@
document.execCommand('copy');
document.body.removeChild(textArea);
setCopyFeedback('Copied!');
} catch (fallbackError) {

Check failure on line 20 in src/components/ShareModal.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.10)

'fallbackError' is defined but never used

Check failure on line 20 in src/components/ShareModal.jsx

View workflow job for this annotation

GitHub Actions / test (20.x, 3.11)

'fallbackError' is defined but never used
setCopyFeedback('Failed to copy');
}
}
Expand All @@ -35,6 +35,8 @@

if (!isOpen) return null;

const isLongURL = urlMetadata && !urlMetadata.isSafe;

return (
<div
style={{
Expand Down Expand Up @@ -84,11 +86,33 @@
{/* Header */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>

<span style={{ fontSize: 18 }}>🔗</span>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
Share Your Patterns
</h3>
</div>
<p style={{ margin: 0, color: '#666', fontSize: 14 }}>
Copy this URL to share your workflow with others.
</p>

{/* URL Length Warning */}
{isLongURL && (
<div style={{
marginTop: 12,
padding: '8px 12px',
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
borderRadius: 4,
fontSize: 13
}}>
<div style={{ color: '#856404', fontWeight: 500, marginBottom: 4 }}>
⚠️ Large URL Warning
</div>
<div style={{ color: '#856404' }}>
This URL is {urlMetadata?.length || 0} characters long. Some servers may reject URLs longer than {urlMetadata?.maxLength || 4000} characters. Consider using the "Save File" option for complex graphs.
</div>
</div>
)}
</div>

{/* URL input and copy button */}
Expand Down
180 changes: 124 additions & 56 deletions src/utils/urlSharing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,52 @@
* URL sharing utilities for PathView
* Handles encoding and decoding graph data in URLs
*/
import { compressToBase64, decompressFromBase64 } from 'lz-string';

// Maximum safe URL length for most servers (conservative estimate)
const MAX_SAFE_URL_LENGTH = 4000;

/**
* Encode graph data to a base64 URL parameter
* Encode graph data to a compressed base64 URL parameter
* @param {Object} graphData - The complete graph data object
* @returns {string} - Base64 encoded string
* @returns {string} - Compressed base64 encoded string
*/
export function encodeGraphData(graphData) {
try {
const jsonString = JSON.stringify(graphData);
// Use btoa for base64 encoding, but handle Unicode strings properly
const utf8Bytes = new TextEncoder().encode(jsonString);
const binaryString = Array.from(utf8Bytes, byte => String.fromCharCode(byte)).join('');
return btoa(binaryString);
} catch (error) {
console.error('Error encoding graph data:', error);
return null;
}
}

/**
* Decode graph data from a base64 URL parameter
* @param {string} encodedData - Base64 encoded graph data
try {
const jsonString = JSON.stringify(graphData);
// Use lz-string for much better compression than manual whitespace removal
return compressToBase64(jsonString);
} catch (error) {
console.error('Error encoding graph data:', error);
return null;
}
}/**
* Decode graph data from a compressed base64 URL parameter
* @param {string} encodedData - Compressed base64 encoded graph data
* @returns {Object|null} - Decoded graph data object or null if error
*/
export function decodeGraphData(encodedData) {
try {
// Decode base64 and handle Unicode properly
const binaryString = atob(encodedData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
// First try lz-string decompression (new format)
const jsonString = decompressFromBase64(encodedData);
if (jsonString) {
return JSON.parse(jsonString);
}

// Fallback for old format (manual base64 encoding)
try {
const binaryString = atob(encodedData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const oldJsonString = new TextDecoder().decode(bytes);
return JSON.parse(oldJsonString);
} catch (oldFormatError) {
console.warn('Could not decode with old format either:', oldFormatError);
}
const jsonString = new TextDecoder().decode(bytes);
return JSON.parse(jsonString);

return null;
} catch (error) {
console.error('Error decoding graph data:', error);
return null;
Expand All @@ -45,27 +57,32 @@ export function decodeGraphData(encodedData) {
/**
* Generate a shareable URL with the current graph data
* @param {Object} graphData - The complete graph data object
* @returns {string} - Complete shareable URL
* @returns {Object} - Object with url and metadata about the URL
*/
export function generateShareableURL(graphData) {
try {
const encodedData = encodeGraphData(graphData);
if (!encodedData) {
throw new Error('Failed to encode graph data');
}

const baseURL = window.location.origin + window.location.pathname;
const url = new URL(baseURL);
url.searchParams.set('graph', encodedData);

return url.toString();
} catch (error) {
console.error('Error generating shareable URL:', error);
return null;
try {
const encodedData = encodeGraphData(graphData);
if (!encodedData) {
throw new Error('Failed to encode graph data');
}
}

/**

const baseURL = window.location.origin + window.location.pathname;
const url = new URL(baseURL);
url.searchParams.set('graph', encodedData);

const finalURL = url.toString();

return {
url: finalURL,
length: finalURL.length,
isSafe: finalURL.length <= MAX_SAFE_URL_LENGTH,
maxLength: MAX_SAFE_URL_LENGTH
};
} catch (error) {
console.error('Error generating shareable URL:', error);
return null;
}
}/**
* Extract graph data from current URL parameters
* @returns {Object|null} - Graph data object or null if not found/error
*/
Expand All @@ -91,21 +108,21 @@ export function getGraphDataFromURL() {
* @param {boolean} replaceState - Whether to replace current history state (default: false)
*/
export function updateURLWithGraphData(graphData, replaceState = false) {
try {
const shareableURL = generateShareableURL(graphData);
if (shareableURL) {
if (replaceState) {
window.history.replaceState({}, '', shareableURL);
} else {
window.history.pushState({}, '', shareableURL);
}
}
} catch (error) {
console.error('Error updating URL with graph data:', error);
try {
const urlResult = generateShareableURL(graphData);
if (urlResult && urlResult.isSafe) {
if (replaceState) {
window.history.replaceState({}, '', urlResult.url);
} else {
window.history.pushState({}, '', urlResult.url);
}
} else if (urlResult) {
console.warn(`URL too long (${urlResult.length} chars), not updating browser URL`);
}
}

/**
} catch (error) {
console.error('Error updating URL with graph data:', error);
}
}/**
* Clear graph data from URL without page reload
*/
export function clearGraphDataFromURL() {
Expand All @@ -116,3 +133,54 @@ export function clearGraphDataFromURL() {
console.error('Error clearing graph data from URL:', error);
}
}

/**
* Copy shareable URL to clipboard
* @param {Object} graphData - The complete graph data object
* @returns {Promise<Object>} - Result object with success status and metadata
*/
export async function copyShareableURLToClipboard(graphData) {
try {
const urlResult = generateShareableURL(graphData);
if (!urlResult) {
throw new Error('Failed to generate shareable URL');
}

await navigator.clipboard.writeText(urlResult.url);
return {
success: true,
isSafe: urlResult.isSafe,
length: urlResult.length,
maxLength: urlResult.maxLength,
url: urlResult.url
};
} catch (error) {
console.error('Error copying to clipboard:', error);
// Fallback for older browsers
try {
const urlResult = generateShareableURL(graphData);
const textArea = document.createElement('textarea');
textArea.value = urlResult.url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
return {
success: true,
isSafe: urlResult.isSafe,
length: urlResult.length,
maxLength: urlResult.maxLength,
url: urlResult.url
};
} catch (fallbackError) {
console.error('Clipboard fallback failed:', fallbackError);
return {
success: false,
isSafe: false,
length: 0,
maxLength: MAX_SAFE_URL_LENGTH,
url: null
};
}
}
}
Loading