Add graph fullscreen mode, collapsible panels, and relation row styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 21:03:01 +03:00
co-authored by Cursor
parent 5155b3a37a
commit b694a596b8
2 changed files with 234 additions and 8 deletions
+148 -3
View File
@@ -1,6 +1,7 @@
<template>
<div class="graph-view">
<div class="graph-view" :class="{ 'graph-view--chrome-collapsed': chromeCollapsed }">
<GraphHeaderPanel
v-show="!chromeCollapsed"
title="Граф связей"
:show-physics-toggle="true"
:physics-enabled="physicsEnabled"
@@ -8,7 +9,7 @@
@toggle-physics="togglePhysics"
/>
<div class="graph-view-toolbar">
<div v-show="!chromeCollapsed" class="graph-view-toolbar">
<p class="graph-link-hint text-muted">
Ctrl+клик (+клик) по двум узлам создать связь.
<span v-if="linkSelectionCount === 1">
@@ -22,7 +23,48 @@
/>
</div>
<div class="graph-area">
<div class="graph-area" ref="graphArea">
<div class="graph-chrome-bar">
<button
type="button"
class="graph-chrome-toggle"
:title="chromeCollapsed ? 'Показать панели' : 'Свернуть панели'"
@click="toggleChrome"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
:class="{ 'graph-chrome-toggle__icon--collapsed': chromeCollapsed }"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
</button>
</div>
<button
v-if="!loading && nodes.length > 0"
type="button"
class="graph-fullscreen-btn"
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
@click="toggleFullscreen"
>
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 14h6v6"/>
<path d="M20 10h-6V4"/>
<path d="M14 10l7-7"/>
<path d="M3 21l7-7"/>
</svg>
</button>
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -149,8 +191,11 @@ function openNodeInfo(node) {
}
const store = useContactsStore()
const graphArea = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
const isFullscreen = ref(false)
const chromeCollapsed = ref(false)
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
@@ -702,6 +747,33 @@ function togglePhysics() {
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
}
async function toggleFullscreen() {
const el = graphArea.value
if (!el) return
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen()
} else {
await el.requestFullscreen()
}
} catch {
// Browser may block fullscreen without user gesture or in unsupported context.
}
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === graphArea.value
nextTick(() => {
network.value?.redraw()
network.value?.fit({ animation: false })
})
}
function toggleChrome() {
chromeCollapsed.value = !chromeCollapsed.value
nextTick(() => network.value?.redraw())
}
watch(() => store.dataRevision, async (revision) => {
if (!network.value || revision === syncedRevision) return
await applyGraphDataFromStore()
@@ -716,6 +788,7 @@ watch(() => store.dataRevision, async (revision) => {
onMounted(() => {
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
document.addEventListener('fullscreenchange', onFullscreenChange)
})
onActivated(async () => {
@@ -738,6 +811,10 @@ onDeactivated(() => {
onUnmounted(() => {
saveLayoutSnapshot()
closeContextMenu()
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement === graphArea.value) {
document.exitFullscreen().catch(() => {})
}
themeObserver?.disconnect()
teardownNetwork()
})
@@ -755,17 +832,85 @@ onUnmounted(() => {
flex-shrink: 0;
padding: 0 28px 10px;
}
.graph-chrome-bar {
display: flex;
justify-content: center;
flex-shrink: 0;
margin-bottom: 6px;
}
.graph-chrome-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-chrome-toggle:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-chrome-toggle svg {
transition: transform 0.2s ease;
}
.graph-chrome-toggle__icon--collapsed {
transform: rotate(180deg);
}
.graph-view--chrome-collapsed .graph-area {
padding-top: 4px;
}
.graph-link-hint {
font-size: 12px;
margin: 0 0 8px;
}
.graph-area {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 28px 20px;
}
.graph-area:fullscreen {
padding: 12px;
background: var(--bg);
}
.graph-area:fullscreen #graph-container {
min-height: 0;
}
.graph-fullscreen-btn {
position: absolute;
top: 8px;
right: 36px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-fullscreen-btn:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-area:fullscreen .graph-fullscreen-btn {
top: 20px;
right: 20px;
}
#graph-container {
flex: 1;
min-height: 300px;