|
|
|
@@ -13,11 +13,14 @@
|
|
|
|
|
X,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Share2,
|
|
|
|
|
Search,
|
|
|
|
|
HelpCircle,
|
|
|
|
|
Moon,
|
|
|
|
|
Sun,
|
|
|
|
|
MoreVertical,
|
|
|
|
|
} from 'lucide-svelte';
|
|
|
|
|
import {
|
|
|
|
|
DB_NAME,
|
|
|
|
@@ -371,6 +374,7 @@
|
|
|
|
|
let newNodeImageError = $state('');
|
|
|
|
|
let controlsCollapsed = $state(false);
|
|
|
|
|
let isMobile = $state(false);
|
|
|
|
|
let mobileToolbarCollapsed = $state(false);
|
|
|
|
|
let copiedNodes = $state<Node[]>([]);
|
|
|
|
|
let searchQuery = $state('');
|
|
|
|
|
let searchInput = $state<HTMLInputElement | null>(null);
|
|
|
|
@@ -379,10 +383,16 @@
|
|
|
|
|
let addNodeInput = $state<HTMLInputElement | null>(null);
|
|
|
|
|
let theme = $state<'dark' | 'light'>('dark');
|
|
|
|
|
let isLight = $derived(theme === 'light');
|
|
|
|
|
let showMoreMenu = $state(false);
|
|
|
|
|
let moreMenuRef = $state<HTMLDivElement | null>(null);
|
|
|
|
|
let touchHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
let touchHoldNodeId = $state<string | null>(null);
|
|
|
|
|
let touchHoldStart = $state({ x: 0, y: 0 });
|
|
|
|
|
let isLongPressing = $state(false);
|
|
|
|
|
let isPinching = $state(false);
|
|
|
|
|
let pinchStartDistance = $state(0);
|
|
|
|
|
let pinchStartScale = $state(1);
|
|
|
|
|
let pinchCenter = $state({ x: 0, y: 0 });
|
|
|
|
|
|
|
|
|
|
let panelClass = $derived(
|
|
|
|
|
isLight
|
|
|
|
@@ -391,8 +401,8 @@
|
|
|
|
|
);
|
|
|
|
|
let iconButtonClass = $derived(
|
|
|
|
|
isLight
|
|
|
|
|
? 'p-2 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
|
|
|
|
|
: 'p-2 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white'
|
|
|
|
|
? 'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
|
|
|
|
|
: 'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white'
|
|
|
|
|
);
|
|
|
|
|
let dividerClass = $derived(
|
|
|
|
|
isLight
|
|
|
|
@@ -1340,11 +1350,46 @@
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTouchDistance(touches: TouchEvent['touches']): number {
|
|
|
|
|
if (touches.length < 2) return 0;
|
|
|
|
|
const dx = touches[0].clientX - touches[1].clientX;
|
|
|
|
|
const dy = touches[0].clientY - touches[1].clientY;
|
|
|
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTouchCenter(touches: TouchEvent['touches']): { x: number; y: number } {
|
|
|
|
|
if (touches.length === 0) return { x: 0, y: 0 };
|
|
|
|
|
if (touches.length === 1) {
|
|
|
|
|
return { x: touches[0].clientX, y: touches[0].clientY };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
x: (touches[0].clientX + touches[1].clientX) / 2,
|
|
|
|
|
y: (touches[0].clientY + touches[1].clientY) / 2,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleTouchStart(e: TouchEvent) {
|
|
|
|
|
if (e.cancelable) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
if (e.touches.length === 0) return;
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 2) {
|
|
|
|
|
isPinching = true;
|
|
|
|
|
pinchStartDistance = getTouchDistance(e.touches);
|
|
|
|
|
pinchStartScale = transform.k;
|
|
|
|
|
const center = getTouchCenter(e.touches);
|
|
|
|
|
const rect = containerElement!.getBoundingClientRect();
|
|
|
|
|
pinchCenter = {
|
|
|
|
|
x: center.x - rect.left,
|
|
|
|
|
y: center.y - rect.top,
|
|
|
|
|
};
|
|
|
|
|
isPanning = false;
|
|
|
|
|
isDragging = false;
|
|
|
|
|
clearTouchHold();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
handleMouseDown(touchToMouseEvent(touch, 'mousedown'));
|
|
|
|
|
}
|
|
|
|
@@ -1354,16 +1399,34 @@
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
if (e.touches.length === 0) return;
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
if (touchHoldTimeout && !isLongPressing) {
|
|
|
|
|
const dx = touch.clientX - touchHoldStart.x;
|
|
|
|
|
const dy = touch.clientY - touchHoldStart.y;
|
|
|
|
|
if (Math.hypot(dx, dy) > 10) {
|
|
|
|
|
clearTouchHold();
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 2 && isPinching) {
|
|
|
|
|
const currentDistance = getTouchDistance(e.touches);
|
|
|
|
|
if (pinchStartDistance > 0) {
|
|
|
|
|
const scaleChange = currentDistance / pinchStartDistance;
|
|
|
|
|
const newScale = Math.min(Math.max(0.1, pinchStartScale * scaleChange), 5);
|
|
|
|
|
|
|
|
|
|
const worldBefore = screenToWorld(pinchCenter.x, pinchCenter.y);
|
|
|
|
|
transform.k = newScale;
|
|
|
|
|
const worldAfter = screenToWorld(pinchCenter.x, pinchCenter.y);
|
|
|
|
|
transform.x += (worldAfter.x - worldBefore.x) * newScale;
|
|
|
|
|
transform.y += (worldAfter.y - worldBefore.y) * newScale;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if ((isLongPressing && touchHoldNodeId) || isPanning) {
|
|
|
|
|
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 1) {
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
if (touchHoldTimeout && !isLongPressing) {
|
|
|
|
|
const dx = touch.clientX - touchHoldStart.x;
|
|
|
|
|
const dy = touch.clientY - touchHoldStart.y;
|
|
|
|
|
if (Math.hypot(dx, dy) > 10) {
|
|
|
|
|
clearTouchHold();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ((isLongPressing && touchHoldNodeId) || isPanning) {
|
|
|
|
|
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1371,6 +1434,15 @@
|
|
|
|
|
if (e.cancelable) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
if (isPinching && e.touches.length < 2) {
|
|
|
|
|
isPinching = false;
|
|
|
|
|
pinchStartDistance = 0;
|
|
|
|
|
pinchStartScale = 1;
|
|
|
|
|
if (e.touches.length === 0) {
|
|
|
|
|
handleMouseUp();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearTouchHold();
|
|
|
|
|
handleMouseUp();
|
|
|
|
|
}
|
|
|
|
@@ -1827,6 +1899,26 @@
|
|
|
|
|
}, 10);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (showMoreMenu) {
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
if (moreMenuRef && e.target instanceof Element) {
|
|
|
|
|
const target = e.target;
|
|
|
|
|
if (!moreMenuRef.contains(target) && !target.closest('[data-more-menu-button]')) {
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
document.addEventListener('click', handleClickOutside, true);
|
|
|
|
|
}, 10);
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
document.removeEventListener('click', handleClickOutside, true);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
@@ -1838,71 +1930,196 @@
|
|
|
|
|
bind:this={containerElement}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 top-1 md:top-2 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 md:top-4 max-w-[calc(100vw-1rem)] md:max-w-none max-h-[calc(100vh-120px)] md:max-h-none mobile-landscape:flex-row mobile-landscape:left-1/2 mobile-landscape:-translate-x-1/2 mobile-landscape:top-auto mobile-landscape:bottom-2 mobile-landscape:max-h-none mobile-landscape:gap-1"
|
|
|
|
|
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 top-1 md:top-2 md:left-4 md:translate-x-0 md:top-4 max-h-[calc(100vh-120px)] md:max-h-none mobile-landscape:flex-row mobile-landscape:left-1/2 mobile-landscape:-translate-x-1/2 mobile-landscape:top-auto mobile-landscape:bottom-2 mobile-landscape:max-h-none mobile-landscape:gap-1 mobile-landscape:max-w-[calc(100vw-1rem)] mobile-landscape:w-auto transition-all duration-300 {mobileToolbarCollapsed
|
|
|
|
|
? 'right-2 md:left-4 md:right-auto'
|
|
|
|
|
: 'left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 w-[calc(100vw-0.25rem)] md:w-auto'}"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class={`rounded-lg p-1 mobile-landscape:p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass} max-h-full overflow-y-auto mobile-landscape:max-h-none mobile-landscape:overflow-visible`}
|
|
|
|
|
>
|
|
|
|
|
{#if !mobileToolbarCollapsed}
|
|
|
|
|
<div
|
|
|
|
|
class="flex flex-row flex-wrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-1.5 md:gap-1.5 mobile-landscape:gap-1 justify-center w-full md:w-auto mobile-landscape:w-auto"
|
|
|
|
|
class={`rounded-lg p-2 mobile-landscape:p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass} max-h-full overflow-visible mobile-landscape:max-h-none mobile-landscape:overflow-visible w-full md:w-auto transition-all`}
|
|
|
|
|
>
|
|
|
|
|
<button class={iconButtonClass} title="Toggle Theme" onclick={toggleTheme}>
|
|
|
|
|
{#if theme === 'dark'}
|
|
|
|
|
<Sun size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
{:else}
|
|
|
|
|
<Moon size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button class={iconButtonClass} title="Add Node" onclick={() => (showAddModal = true)}>
|
|
|
|
|
<Plus size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button class={iconButtonClass} title="Import Graph" onclick={importGraph}>
|
|
|
|
|
<Upload size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Export JSON" onclick={exportGraph}>
|
|
|
|
|
<Download size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Share Link" onclick={shareGraph}>
|
|
|
|
|
<Share2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="Keyboard Shortcuts (?)"
|
|
|
|
|
onclick={() => (showShortcutsModal = true)}
|
|
|
|
|
<div
|
|
|
|
|
class="flex flex-row flex-nowrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-2 md:gap-1.5 mobile-landscape:gap-1 justify-start md:justify-center mobile-landscape:justify-center w-full md:w-auto mobile-landscape:w-auto overflow-visible md:overflow-visible items-center"
|
|
|
|
|
>
|
|
|
|
|
<HelpCircle size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="Undo (Ctrl+Z)"
|
|
|
|
|
onclick={undo}
|
|
|
|
|
disabled={undoCount === 0}
|
|
|
|
|
class:opacity-50={undoCount === 0}
|
|
|
|
|
>
|
|
|
|
|
<Undo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="Redo (Ctrl+Y)"
|
|
|
|
|
onclick={redo}
|
|
|
|
|
disabled={redoCount === 0}
|
|
|
|
|
class:opacity-50={redoCount === 0}
|
|
|
|
|
>
|
|
|
|
|
<Redo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button class={iconButtonClass} title="Fit to Screen" onclick={centerView}>
|
|
|
|
|
<Maximize size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Clear Graph" onclick={clearGraph}>
|
|
|
|
|
<Trash2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button class={iconButtonClass} title="Toggle Theme" onclick={toggleTheme}>
|
|
|
|
|
{#if theme === 'dark'}
|
|
|
|
|
<Sun size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
{:else}
|
|
|
|
|
<Moon size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Add Node"
|
|
|
|
|
onclick={() => (showAddModal = true)}
|
|
|
|
|
>
|
|
|
|
|
<Plus size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={`${dividerClass} hidden md:block mobile-landscape:block`}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Import Graph"
|
|
|
|
|
onclick={importGraph}
|
|
|
|
|
>
|
|
|
|
|
<Upload size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Export JSON"
|
|
|
|
|
onclick={exportGraph}
|
|
|
|
|
>
|
|
|
|
|
<Download size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Share Link"
|
|
|
|
|
onclick={shareGraph}
|
|
|
|
|
>
|
|
|
|
|
<Share2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Keyboard Shortcuts (?)"
|
|
|
|
|
onclick={() => (showShortcutsModal = true)}
|
|
|
|
|
>
|
|
|
|
|
<HelpCircle size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="Undo (Ctrl+Z)"
|
|
|
|
|
onclick={undo}
|
|
|
|
|
disabled={undoCount === 0}
|
|
|
|
|
class:opacity-50={undoCount === 0}
|
|
|
|
|
>
|
|
|
|
|
<Undo2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="Redo (Ctrl+Y)"
|
|
|
|
|
onclick={redo}
|
|
|
|
|
disabled={redoCount === 0}
|
|
|
|
|
class:opacity-50={redoCount === 0}
|
|
|
|
|
>
|
|
|
|
|
<Redo2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={dividerClass}></div>
|
|
|
|
|
<button class={iconButtonClass} title="Fit to Screen" onclick={centerView}>
|
|
|
|
|
<Maximize size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
|
|
|
|
|
title="Clear Graph"
|
|
|
|
|
onclick={clearGraph}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<div class={`${dividerClass} hidden md:block mobile-landscape:block`}></div>
|
|
|
|
|
<div class="relative md:hidden mobile-landscape:hidden">
|
|
|
|
|
<button
|
|
|
|
|
class={iconButtonClass}
|
|
|
|
|
title="More options"
|
|
|
|
|
data-more-menu-button
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
showMoreMenu = !showMoreMenu;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<MoreVertical size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
{#if showMoreMenu}
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
|
<div
|
|
|
|
|
bind:this={moreMenuRef}
|
|
|
|
|
class={`absolute top-full right-0 mt-1 rounded-lg shadow-lg border ${panelClass} z-[100] min-w-[180px] pointer-events-auto`}
|
|
|
|
|
onclick={(e) => e.stopPropagation()}
|
|
|
|
|
role="menu"
|
|
|
|
|
tabindex="-1"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 hover:bg-neutral-800/50 transition-colors ${
|
|
|
|
|
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => {
|
|
|
|
|
showAddModal = true;
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Plus size={16} />
|
|
|
|
|
Add Node
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 hover:bg-neutral-800/50 transition-colors ${
|
|
|
|
|
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => {
|
|
|
|
|
importGraph();
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Upload size={16} />
|
|
|
|
|
Import Graph
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 hover:bg-neutral-800/50 transition-colors ${
|
|
|
|
|
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => {
|
|
|
|
|
exportGraph();
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Download size={16} />
|
|
|
|
|
Export JSON
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 hover:bg-neutral-800/50 transition-colors ${
|
|
|
|
|
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => {
|
|
|
|
|
shareGraph();
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Share2 size={16} />
|
|
|
|
|
Share Link
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 hover:bg-neutral-800/50 transition-colors ${
|
|
|
|
|
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => {
|
|
|
|
|
clearGraph();
|
|
|
|
|
showMoreMenu = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
Clear Graph
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} md:hidden mobile-landscape:hidden ml-auto`}
|
|
|
|
|
title="Collapse toolbar"
|
|
|
|
|
onclick={() => (mobileToolbarCollapsed = !mobileToolbarCollapsed)}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<button
|
|
|
|
|
class={`${iconButtonClass} md:hidden mobile-landscape:hidden pointer-events-auto rounded-lg p-2 shadow-lg border ${panelClass} w-auto`}
|
|
|
|
|
title="Expand toolbar"
|
|
|
|
|
onclick={() => (mobileToolbarCollapsed = !mobileToolbarCollapsed)}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if showSearch}
|
|
|
|
@@ -1946,7 +2163,24 @@
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
|
|
|
|
|
<div class="fixed bottom-14 right-4 z-20 pointer-events-none md:hidden mobile-landscape:hidden">
|
|
|
|
|
<button
|
|
|
|
|
class={`rounded-full p-4 pointer-events-auto shadow-lg border-2 transition-transform hover:scale-110 active:scale-95 ${
|
|
|
|
|
isLight
|
|
|
|
|
? 'bg-rose-600 border-rose-700 text-white hover:bg-rose-500'
|
|
|
|
|
: 'bg-rose-600 border-rose-700 text-white hover:bg-rose-500'
|
|
|
|
|
}`}
|
|
|
|
|
title="Add Node"
|
|
|
|
|
onclick={() => (showAddModal = true)}
|
|
|
|
|
aria-label="Add Node"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={24} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
class="absolute bottom-4 right-4 z-10 pointer-events-none hidden md:block mobile-landscape:block"
|
|
|
|
|
>
|
|
|
|
|
<div class={`backdrop-blur rounded-lg pointer-events-auto shadow-lg border ${panelClass}`}>
|
|
|
|
|
<button
|
|
|
|
|
class={`w-full flex items-center justify-between px-3 py-2 text-[10px] uppercase tracking-wider font-semibold transition-colors ${
|
|
|
|
|