Skip to content

Commit 4d5167c

Browse files
Merge pull request #166 from festim-dev/share-url
URL compression
2 parents 77ddb67 + 8431f52 commit 4d5167c

File tree

5 files changed

+180
-69
lines changed

5 files changed

+180
-69
lines changed

package-lock.json

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
"start:both": "concurrently \"npm run dev\" \"npm run start:backend\""
1313
},
1414
"dependencies": {
15+
"@codemirror/lang-python": "^6.2.1",
16+
"@uiw/codemirror-theme-vscode": "^4.24.2",
17+
"@uiw/react-codemirror": "^4.24.2",
1518
"@xyflow/react": "^12.8.1",
19+
"lz-string": "^1.5.0",
1620
"plotly.js": "^3.0.3",
1721
"react": "^18.2.0",
1822
"react-dom": "^18.2.0",
19-
"react-plotly.js": "^2.6.0",
20-
"@uiw/react-codemirror": "^4.24.2",
21-
"@uiw/codemirror-theme-vscode": "^4.24.2",
22-
"@codemirror/lang-python": "^6.2.1"
23+
"react-plotly.js": "^2.6.0"
2324
},
2425
"devDependencies": {
2526
"@eslint/js": "^9.25.0",
@@ -33,4 +34,4 @@
3334
"globals": "^16.0.0",
3435
"vite": "^6.3.5"
3536
}
36-
}
37+
}

src/App.jsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const DnDFlow = () => {
110110
const [shareUrlFeedback, setShareUrlFeedback] = useState('');
111111
const [showShareModal, setShowShareModal] = useState(false);
112112
const [shareableURL, setShareableURL] = useState('');
113+
const [urlMetadata, setUrlMetadata] = useState(null);
113114

114115
// Load graph data from URL on component mount
115116
useEffect(() => {
@@ -581,12 +582,19 @@ const DnDFlow = () => {
581582
};
582583

583584
try {
584-
const url = generateShareableURL(graphData);
585-
if (url) {
586-
setShareableURL(url);
585+
const urlResult = generateShareableURL(graphData);
586+
if (urlResult) {
587+
setShareableURL(urlResult.url);
588+
setUrlMetadata({
589+
length: urlResult.length,
590+
isSafe: urlResult.isSafe,
591+
maxLength: urlResult.maxLength
592+
});
587593
setShowShareModal(true);
588-
// Update browser URL as well
589-
updateURLWithGraphData(graphData, true);
594+
// Only update browser URL if it's safe length
595+
if (urlResult.isSafe) {
596+
updateURLWithGraphData(graphData, true);
597+
}
590598
} else {
591599
setShareUrlFeedback('Error generating share URL');
592600
setTimeout(() => setShareUrlFeedback(''), 3000);
@@ -1219,6 +1227,7 @@ const DnDFlow = () => {
12191227
isOpen={showShareModal}
12201228
onClose={() => setShowShareModal(false)}
12211229
shareableURL={shareableURL}
1230+
urlMetadata={urlMetadata}
12221231
/>
12231232

12241233
</div>

src/components/ShareModal.jsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect } from 'react';
22

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

66
const handleCopy = async () => {
@@ -35,6 +35,8 @@ const ShareModal = ({ isOpen, onClose, shareableURL }) => {
3535

3636
if (!isOpen) return null;
3737

38+
const isLongURL = urlMetadata && !urlMetadata.isSafe;
39+
3840
return (
3941
<div
4042
style={{
@@ -84,11 +86,33 @@ const ShareModal = ({ isOpen, onClose, shareableURL }) => {
8486
{/* Header */}
8587
<div style={{ marginBottom: 16 }}>
8688
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
87-
89+
<span style={{ fontSize: 18 }}>🔗</span>
90+
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
91+
Share Your Patterns
92+
</h3>
8893
</div>
8994
<p style={{ margin: 0, color: '#666', fontSize: 14 }}>
9095
Copy this URL to share your workflow with others.
9196
</p>
97+
98+
{/* URL Length Warning */}
99+
{isLongURL && (
100+
<div style={{
101+
marginTop: 12,
102+
padding: '8px 12px',
103+
backgroundColor: '#fff3cd',
104+
border: '1px solid #ffeeba',
105+
borderRadius: 4,
106+
fontSize: 13
107+
}}>
108+
<div style={{ color: '#856404', fontWeight: 500, marginBottom: 4 }}>
109+
⚠️ Large URL Warning
110+
</div>
111+
<div style={{ color: '#856404' }}>
112+
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.
113+
</div>
114+
</div>
115+
)}
92116
</div>
93117

94118
{/* URL input and copy button */}

src/utils/urlSharing.js

Lines changed: 124 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,52 @@
22
* URL sharing utilities for PathView
33
* Handles encoding and decoding graph data in URLs
44
*/
5+
import { compressToBase64, decompressFromBase64 } from 'lz-string';
6+
7+
// Maximum safe URL length for most servers (conservative estimate)
8+
const MAX_SAFE_URL_LENGTH = 4000;
59

610
/**
7-
* Encode graph data to a base64 URL parameter
11+
* Encode graph data to a compressed base64 URL parameter
812
* @param {Object} graphData - The complete graph data object
9-
* @returns {string} - Base64 encoded string
13+
* @returns {string} - Compressed base64 encoded string
1014
*/
1115
export function encodeGraphData(graphData) {
12-
try {
13-
const jsonString = JSON.stringify(graphData);
14-
// Use btoa for base64 encoding, but handle Unicode strings properly
15-
const utf8Bytes = new TextEncoder().encode(jsonString);
16-
const binaryString = Array.from(utf8Bytes, byte => String.fromCharCode(byte)).join('');
17-
return btoa(binaryString);
18-
} catch (error) {
19-
console.error('Error encoding graph data:', error);
20-
return null;
21-
}
22-
}
23-
24-
/**
25-
* Decode graph data from a base64 URL parameter
26-
* @param {string} encodedData - Base64 encoded graph data
16+
try {
17+
const jsonString = JSON.stringify(graphData);
18+
// Use lz-string for much better compression than manual whitespace removal
19+
return compressToBase64(jsonString);
20+
} catch (error) {
21+
console.error('Error encoding graph data:', error);
22+
return null;
23+
}
24+
}/**
25+
* Decode graph data from a compressed base64 URL parameter
26+
* @param {string} encodedData - Compressed base64 encoded graph data
2727
* @returns {Object|null} - Decoded graph data object or null if error
2828
*/
2929
export function decodeGraphData(encodedData) {
3030
try {
31-
// Decode base64 and handle Unicode properly
32-
const binaryString = atob(encodedData);
33-
const bytes = new Uint8Array(binaryString.length);
34-
for (let i = 0; i < binaryString.length; i++) {
35-
bytes[i] = binaryString.charCodeAt(i);
31+
// First try lz-string decompression (new format)
32+
const jsonString = decompressFromBase64(encodedData);
33+
if (jsonString) {
34+
return JSON.parse(jsonString);
35+
}
36+
37+
// Fallback for old format (manual base64 encoding)
38+
try {
39+
const binaryString = atob(encodedData);
40+
const bytes = new Uint8Array(binaryString.length);
41+
for (let i = 0; i < binaryString.length; i++) {
42+
bytes[i] = binaryString.charCodeAt(i);
43+
}
44+
const oldJsonString = new TextDecoder().decode(bytes);
45+
return JSON.parse(oldJsonString);
46+
} catch (oldFormatError) {
47+
console.warn('Could not decode with old format either:', oldFormatError);
3648
}
37-
const jsonString = new TextDecoder().decode(bytes);
38-
return JSON.parse(jsonString);
49+
50+
return null;
3951
} catch (error) {
4052
console.error('Error decoding graph data:', error);
4153
return null;
@@ -45,27 +57,32 @@ export function decodeGraphData(encodedData) {
4557
/**
4658
* Generate a shareable URL with the current graph data
4759
* @param {Object} graphData - The complete graph data object
48-
* @returns {string} - Complete shareable URL
60+
* @returns {Object} - Object with url and metadata about the URL
4961
*/
5062
export function generateShareableURL(graphData) {
51-
try {
52-
const encodedData = encodeGraphData(graphData);
53-
if (!encodedData) {
54-
throw new Error('Failed to encode graph data');
55-
}
56-
57-
const baseURL = window.location.origin + window.location.pathname;
58-
const url = new URL(baseURL);
59-
url.searchParams.set('graph', encodedData);
60-
61-
return url.toString();
62-
} catch (error) {
63-
console.error('Error generating shareable URL:', error);
64-
return null;
63+
try {
64+
const encodedData = encodeGraphData(graphData);
65+
if (!encodedData) {
66+
throw new Error('Failed to encode graph data');
6567
}
66-
}
67-
68-
/**
68+
69+
const baseURL = window.location.origin + window.location.pathname;
70+
const url = new URL(baseURL);
71+
url.searchParams.set('graph', encodedData);
72+
73+
const finalURL = url.toString();
74+
75+
return {
76+
url: finalURL,
77+
length: finalURL.length,
78+
isSafe: finalURL.length <= MAX_SAFE_URL_LENGTH,
79+
maxLength: MAX_SAFE_URL_LENGTH
80+
};
81+
} catch (error) {
82+
console.error('Error generating shareable URL:', error);
83+
return null;
84+
}
85+
}/**
6986
* Extract graph data from current URL parameters
7087
* @returns {Object|null} - Graph data object or null if not found/error
7188
*/
@@ -91,21 +108,21 @@ export function getGraphDataFromURL() {
91108
* @param {boolean} replaceState - Whether to replace current history state (default: false)
92109
*/
93110
export function updateURLWithGraphData(graphData, replaceState = false) {
94-
try {
95-
const shareableURL = generateShareableURL(graphData);
96-
if (shareableURL) {
97-
if (replaceState) {
98-
window.history.replaceState({}, '', shareableURL);
99-
} else {
100-
window.history.pushState({}, '', shareableURL);
101-
}
102-
}
103-
} catch (error) {
104-
console.error('Error updating URL with graph data:', error);
111+
try {
112+
const urlResult = generateShareableURL(graphData);
113+
if (urlResult && urlResult.isSafe) {
114+
if (replaceState) {
115+
window.history.replaceState({}, '', urlResult.url);
116+
} else {
117+
window.history.pushState({}, '', urlResult.url);
118+
}
119+
} else if (urlResult) {
120+
console.warn(`URL too long (${urlResult.length} chars), not updating browser URL`);
105121
}
106-
}
107-
108-
/**
122+
} catch (error) {
123+
console.error('Error updating URL with graph data:', error);
124+
}
125+
}/**
109126
* Clear graph data from URL without page reload
110127
*/
111128
export function clearGraphDataFromURL() {
@@ -116,3 +133,54 @@ export function clearGraphDataFromURL() {
116133
console.error('Error clearing graph data from URL:', error);
117134
}
118135
}
136+
137+
/**
138+
* Copy shareable URL to clipboard
139+
* @param {Object} graphData - The complete graph data object
140+
* @returns {Promise<Object>} - Result object with success status and metadata
141+
*/
142+
export async function copyShareableURLToClipboard(graphData) {
143+
try {
144+
const urlResult = generateShareableURL(graphData);
145+
if (!urlResult) {
146+
throw new Error('Failed to generate shareable URL');
147+
}
148+
149+
await navigator.clipboard.writeText(urlResult.url);
150+
return {
151+
success: true,
152+
isSafe: urlResult.isSafe,
153+
length: urlResult.length,
154+
maxLength: urlResult.maxLength,
155+
url: urlResult.url
156+
};
157+
} catch (error) {
158+
console.error('Error copying to clipboard:', error);
159+
// Fallback for older browsers
160+
try {
161+
const urlResult = generateShareableURL(graphData);
162+
const textArea = document.createElement('textarea');
163+
textArea.value = urlResult.url;
164+
document.body.appendChild(textArea);
165+
textArea.select();
166+
document.execCommand('copy');
167+
document.body.removeChild(textArea);
168+
return {
169+
success: true,
170+
isSafe: urlResult.isSafe,
171+
length: urlResult.length,
172+
maxLength: urlResult.maxLength,
173+
url: urlResult.url
174+
};
175+
} catch (fallbackError) {
176+
console.error('Clipboard fallback failed:', fallbackError);
177+
return {
178+
success: false,
179+
isSafe: false,
180+
length: 0,
181+
maxLength: MAX_SAFE_URL_LENGTH,
182+
url: null
183+
};
184+
}
185+
}
186+
}

0 commit comments

Comments
 (0)