|
|
|
@@ -4,7 +4,6 @@
|
|
|
|
|
Plus,
|
|
|
|
|
Upload,
|
|
|
|
|
Download,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
Undo2,
|
|
|
|
|
Redo2,
|
|
|
|
|
Maximize,
|
|
|
|
@@ -109,8 +108,10 @@
|
|
|
|
|
let hoverNodeId: string | null = null;
|
|
|
|
|
let editingLinkId: string | null = null;
|
|
|
|
|
let editingLinkLabel = '';
|
|
|
|
|
let editingLinkType: RelationshipType = 'Linked';
|
|
|
|
|
let editingLinkType: string = 'Linked';
|
|
|
|
|
let editingLinkTypeManuallyEdited = false;
|
|
|
|
|
let editingLinkStrength: RelationshipStrength = 'medium';
|
|
|
|
|
let linkTypeEditInput: HTMLInputElement | null = null;
|
|
|
|
|
let selectedNode: Node | null = null;
|
|
|
|
|
let isSelecting = false;
|
|
|
|
|
let selectionBox: { x1: number; y1: number; x2: number; y2: number } | null = null;
|
|
|
|
@@ -478,11 +479,15 @@
|
|
|
|
|
try {
|
|
|
|
|
const decoded = atob(encoded);
|
|
|
|
|
const data = JSON.parse(decoded);
|
|
|
|
|
|
|
|
|
|
if (!data || typeof data !== 'object') return false;
|
|
|
|
|
if (!Array.isArray(data.nodes) || !Array.isArray(data.links)) return false;
|
|
|
|
|
|
|
|
|
|
if (data.nodes && data.links) {
|
|
|
|
|
pushState();
|
|
|
|
|
nodes = normalizeNodes(data.nodes);
|
|
|
|
|
links = data.links;
|
|
|
|
|
if (data.transform) {
|
|
|
|
|
if (data.transform && typeof data.transform === 'object') {
|
|
|
|
|
transform = data.transform;
|
|
|
|
|
} else {
|
|
|
|
|
centerView();
|
|
|
|
@@ -529,6 +534,14 @@
|
|
|
|
|
const encoded = btoa(jsonData);
|
|
|
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}?graph=${encoded}`;
|
|
|
|
|
|
|
|
|
|
const MAX_URL_LENGTH = 2000;
|
|
|
|
|
if (shareUrl.length > MAX_URL_LENGTH) {
|
|
|
|
|
alert(
|
|
|
|
|
'Graph data is too large to share via URL. Please use the JSON export feature instead.'
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
|
|
|
alert('Share link copied to clipboard!');
|
|
|
|
@@ -616,55 +629,6 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportPNG() {
|
|
|
|
|
if (!svgElement) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const serializer = new XMLSerializer();
|
|
|
|
|
const svgStr = serializer.serializeToString(svgElement);
|
|
|
|
|
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
|
|
|
|
const url = URL.createObjectURL(svgBlob);
|
|
|
|
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
|
|
|
|
const bbox = svgElement.getBoundingClientRect();
|
|
|
|
|
canvas.width = bbox.width * 2;
|
|
|
|
|
canvas.height = bbox.height * 2;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#0a0a0a';
|
|
|
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
ctx.scale(2, 2);
|
|
|
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
|
|
|
|
|
|
canvas.toBlob((blob) => {
|
|
|
|
|
if (blob) {
|
|
|
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.href = blobUrl;
|
|
|
|
|
link.download = `quad4-linking-snapshot-${new Date().toISOString().slice(0, 10)}.png`;
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
link.click();
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
URL.revokeObjectURL(blobUrl);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
}, 'image/png');
|
|
|
|
|
};
|
|
|
|
|
img.onerror = () => {
|
|
|
|
|
console.error('Failed to load SVG image');
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
};
|
|
|
|
|
img.src = url;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to export PNG', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getScreenCoords(e: MouseEvent) {
|
|
|
|
|
const rect = containerElement.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
@@ -981,6 +945,8 @@
|
|
|
|
|
if (link) {
|
|
|
|
|
editingLinkId = linkId;
|
|
|
|
|
editingLinkLabel = link.label;
|
|
|
|
|
editingLinkType = link.type || 'Linked';
|
|
|
|
|
editingLinkTypeManuallyEdited = false;
|
|
|
|
|
setTimeout(() => linkEditInput?.focus(), 10);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -988,12 +954,13 @@
|
|
|
|
|
function saveLinkEdit() {
|
|
|
|
|
if (!editingLinkId) return;
|
|
|
|
|
pushState();
|
|
|
|
|
const linkType = editingLinkType.trim() || 'Linked';
|
|
|
|
|
links = links.map((l) =>
|
|
|
|
|
l.id === editingLinkId
|
|
|
|
|
? {
|
|
|
|
|
...l,
|
|
|
|
|
label: editingLinkLabel.trim() || editingLinkType,
|
|
|
|
|
type: editingLinkType,
|
|
|
|
|
label: editingLinkLabel.trim() || linkType,
|
|
|
|
|
type: linkType as RelationshipType,
|
|
|
|
|
strength: editingLinkStrength,
|
|
|
|
|
}
|
|
|
|
|
: l
|
|
|
|
@@ -1001,6 +968,7 @@
|
|
|
|
|
editingLinkId = null;
|
|
|
|
|
editingLinkLabel = '';
|
|
|
|
|
editingLinkType = 'Linked';
|
|
|
|
|
editingLinkTypeManuallyEdited = false;
|
|
|
|
|
editingLinkStrength = 'medium';
|
|
|
|
|
saveToLocalStorage();
|
|
|
|
|
}
|
|
|
|
@@ -1008,6 +976,18 @@
|
|
|
|
|
function cancelLinkEdit() {
|
|
|
|
|
editingLinkId = null;
|
|
|
|
|
editingLinkLabel = '';
|
|
|
|
|
editingLinkType = 'Linked';
|
|
|
|
|
editingLinkTypeManuallyEdited = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleRelationshipTypeButtonClick(relType: RelationshipType) {
|
|
|
|
|
if (!editingLinkTypeManuallyEdited) {
|
|
|
|
|
editingLinkType = relType;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleRelationshipTypeInput() {
|
|
|
|
|
editingLinkTypeManuallyEdited = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function centerView() {
|
|
|
|
@@ -1044,9 +1024,22 @@
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isValidImageUrl(url: string): boolean {
|
|
|
|
|
if (!url || typeof url !== 'string') return false;
|
|
|
|
|
const trimmed = url.trim();
|
|
|
|
|
if (!trimmed) return false;
|
|
|
|
|
|
|
|
|
|
if (trimmed.startsWith('javascript:')) return false;
|
|
|
|
|
if (trimmed.startsWith('data:')) {
|
|
|
|
|
return trimmed.startsWith('data:image/');
|
|
|
|
|
}
|
|
|
|
|
return trimmed.startsWith('http://') || trimmed.startsWith('https://');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeNodes(nodesToNormalize: Node[]): Node[] {
|
|
|
|
|
return nodesToNormalize.map((node) => ({
|
|
|
|
|
...node,
|
|
|
|
|
imageUrl: node.imageUrl && isValidImageUrl(node.imageUrl) ? node.imageUrl : undefined,
|
|
|
|
|
showLabel: node.showLabel !== undefined ? node.showLabel : true,
|
|
|
|
|
showType: node.showType !== undefined ? node.showType : true,
|
|
|
|
|
showNotes: node.showNotes !== undefined ? node.showNotes : true,
|
|
|
|
@@ -1242,9 +1235,6 @@
|
|
|
|
|
<button class={iconButtonClass} title="Export JSON" on:click={exportGraph}>
|
|
|
|
|
<Download size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Export PNG" on:click={exportPNG}>
|
|
|
|
|
<ImageIcon size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Share Link" on:click={shareGraph}>
|
|
|
|
|
<Share2 size={16} />
|
|
|
|
|
</button>
|
|
|
|
@@ -2112,7 +2102,21 @@
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Relationship Type</div>
|
|
|
|
|
<label for="linkType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
|
|
|
|
|
>Relationship Type</label
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
id="linkType"
|
|
|
|
|
bind:this={linkTypeEditInput}
|
|
|
|
|
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass} mb-2`}
|
|
|
|
|
placeholder="Relationship type"
|
|
|
|
|
bind:value={editingLinkType}
|
|
|
|
|
on:input={handleRelationshipTypeInput}
|
|
|
|
|
on:keydown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') saveLinkEdit();
|
|
|
|
|
if (e.key === 'Escape') cancelLinkEdit();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div class="grid grid-cols-3 gap-2" role="group" aria-label="Relationship Type">
|
|
|
|
|
{#each relationshipTypes as relType}
|
|
|
|
|
{@const relColor = RELATIONSHIP_COLORS[relType]}
|
|
|
|
@@ -2123,8 +2127,9 @@
|
|
|
|
|
: isLight
|
|
|
|
|
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
|
|
|
|
|
: 'border-neutral-800 bg-neutral-800 text-neutral-400 hover:border-neutral-700')}
|
|
|
|
|
on:click={() => (editingLinkType = relType)}
|
|
|
|
|
on:click={() => handleRelationshipTypeButtonClick(relType)}
|
|
|
|
|
style={editingLinkType === relType ? `border-color: ${relColor}` : ''}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
{relType}
|
|
|
|
|
</button>
|
|
|
|
|