From d97f82beb234c74c479f04aea743e8ff172523c7 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 09:43:33 +0200 Subject: [PATCH] Graph colours by depth, pan button and directory settings --- Backend/Settings.py | 4 + Client/Browser.py | 2 +- Client/Core.py | 4 + Client/Graph.py | 188 ++++++++++++++++++---- Client/Settings.py | 24 +++ Frontend/src/App.jsx | 2 + Frontend/src/Components/GraphPane.jsx | 77 ++++++--- Frontend/src/Components/SettingsPanel.jsx | 19 +++ Frontend/src/Styles/Theme.css | 14 ++ README.md | 12 +- 10 files changed, 288 insertions(+), 58 deletions(-) diff --git a/Backend/Settings.py b/Backend/Settings.py index 3fb5d3c..209d02d 100644 --- a/Backend/Settings.py +++ b/Backend/Settings.py @@ -48,6 +48,10 @@ DefaultSettings: dict[str, Any] = { # "Live": Markdown is edited where it's shown rendered (Obsidian-style # live preview). "Separate": rendered View and raw Edit are two modes. "MarkdownEditing": "Live", + # Graph view: directories as dots too (off: only files and the references + # between them), and the mouse button that pans ("Left", "Middle", "Right"). + "GraphShowDirectories": True, + "GraphPanButton": "Middle", } DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" diff --git a/Client/Browser.py b/Client/Browser.py index 6cbb606..71a48fe 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -200,7 +200,7 @@ class Browser(ft.Column): if self.OpenView is not None: return # reloading would throw away edits; close the file first if self.GraphView is not None: - self.GraphView.Reload() + self.ShowGraph() # a new one, so changed graph settings apply return self._LoadEntries() self._ShowList() diff --git a/Client/Core.py b/Client/Core.py index 34669af..619c6a2 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -167,6 +167,10 @@ class AppConfig: # text. "Live": rendered, and clicking a block shows its Markdown to edit # in place (like Obsidian's live preview). Only affects Markdown. MarkdownEditing: str = "Live" + # Graph view: directories as dots too (off: only files and the references + # between them), and the mouse button that pans ("Left", "Middle", "Right"). + GraphShowDirectories: bool = True + GraphPanButton: str = "Middle" # Double-click on a file: True opens it in SaraView's own viewer, False # hands it to the system's default program. The menu always offers the other. OpenFilesInApp: bool = True diff --git a/Client/Graph.py b/Client/Graph.py index 3b73f73..5707ee2 100644 --- a/Client/Graph.py +++ b/Client/Graph.py @@ -1,12 +1,12 @@ """The graph view, like Obsidian's (and the web view's GraphPane.jsx): every -directory and file under a directory as a dot (directories Mauve, files -White), joined to its parent directory and to every file that references it. +directory and file under a directory as a dot (directories in the heading +colours by depth, files White), joined to its parent directory and to every file that references it. On a server the graph comes from /api/graph; locally BuildLocalGraph makes the same one with the same reference rules as Backend/Graph.py. The layout is the web view's force simulation (same constants), in numpy, drawn as it -settles. Scroll or pinch to zoom, drag to pan, hover a dot to light up its -links, click it to open it.""" +settles. Scroll to zoom, pan with the button picked in Settings > Graph, +hover a dot to light up its links, click it to open it.""" import math import re @@ -217,20 +217,54 @@ class Layout: # --- View -------------------------------------------------------------------- +# Directories go down the heading colours by depth (h1 Mauve, h2 Blue, ...), +# starting again after Red. Files are White. +DepthColours = ["Mauve", "Blue", "Green", "Yellow", "Peach", "Red"] + + +def DirectoryColour(Depth: int) -> str: + return getattr(Palette, DepthColours[Depth % len(DepthColours)]) + + +def FilterGraph(Graph: dict, ShowDirectories: bool) -> dict: + """Without directories: just the files and the references between them + (files with none float on their own).""" + if ShowDirectories: + return Graph + Nodes = [Node for Node in Graph["Nodes"] if not Node["IsDirectory"]] + Links = [Link for Link in Graph["Links"] if Link["Kind"] == "Reference"] + return {**Graph, "Nodes": Nodes, "Links": Links} + + class GraphView(ft.Container): + """Pan and zoom are done here rather than with an InteractiveViewer, so + the pan button can be picked (Settings > Graph). Flet reports drags for + the left and right buttons only, so Middle works like middle-click + autoscroll: click it to grab the graph, move to pan, click again to let go.""" + def __init__(self, TheApp: App, Root: str, DirectoryPath: str, OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]): self.TheApp = TheApp self.Root = Root self.DirectoryPath = DirectoryPath self.OnOpenNode = OnOpenNode + self.PanButton = TheApp.Config.GraphPanButton + self.ShowDirectories = TheApp.Config.GraphShowDirectories self.Layout: Optional[Layout] = None + self.ScopePath = "" self.Size = (800.0, 600.0) self.Screen = np.zeros((0, 2)) # each dot's position on the canvas self.Radius = np.zeros(0) + self.Colours: list[str] = [] self.Hovered: Optional[int] = None self.Closed = False self.Generation = 0 # bumps when the scope changes, so an old layout thread stops + # The view: canvas point P shows at P * Zoom + Pan. + self.Zoom = 1.0 + self.PanX = 0.0 + self.PanY = 0.0 + self.DragFrom: Optional[tuple[float, float]] = None + self.Grabbed = False # Middle: grabbed by a middle click self.ScopePicker = ft.Dropdown( value="Root", @@ -241,24 +275,36 @@ class GraphView(ft.Container): on_select=lambda e: self.Reload(), ) self.CountText = ft.Text("", size=12, color=Palette.Overlay0) - Legend = ft.Row([ - ft.Text("●", color=Palette.Mauve), ft.Text("Directory", size=12, color=Palette.Overlay0), - ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0), - ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0), - ], spacing=4, tight=True) + LegendItems: list[ft.Control] = [] + if self.ShowDirectories: + LegendItems += [ft.Text("●", color=Palette.Mauve), ft.Text("●", color=Palette.Blue), ft.Text("●", color=Palette.Green), + ft.Text("Directories by depth", size=12, color=Palette.Overlay0)] + LegendItems += [ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0), + ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0)] self.BaseCanvas = cv.Canvas(shapes=[], expand=True, on_resize=self._OnResize, resize_interval=100) self.LitCanvas = cv.Canvas(shapes=[], expand=True) + self.Plane = ft.Container(content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True), expand=True) self.Status = ft.Text("Building the graph…", color=Palette.Overlay0) - Surface = ft.GestureDetector( - content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True), + # Hidden (not just its text) once the graph is up: even empty, it sits + # over the graph and would catch every click. + self.StatusBox = ft.Container(self.Status, alignment=ft.Alignment.CENTER) + Hint = {"Left": "Drag to pan", "Right": "Right-drag to pan", + "Middle": "Middle-click to grab and pan, again to let go"}.get(self.PanButton, "") + self.Surface = ft.GestureDetector( + content=ft.Container(self.Plane, expand=True, clip_behavior=ft.ClipBehavior.HARD_EDGE, bgcolor=Palette.Base), on_tap_up=self._OnTap, on_hover=self._OnHover, - hover_interval=50, - mouse_cursor=ft.MouseCursor.BASIC, - expand=True, - ) - self.Viewer = ft.InteractiveViewer( - content=Surface, min_scale=0.2, max_scale=8, boundary_margin=ft.Margin.all(4000), + on_exit=lambda e: self._SetHovered(None), + on_scroll=self._OnScroll, + on_pan_start=lambda e: self._StartDrag(e, "Left"), + on_pan_update=lambda e: self._Drag(e, "Left"), + on_pan_end=lambda e: self._EndDrag(), + on_right_pan_start=lambda e: self._StartDrag(e, "Right"), + on_right_pan_update=lambda e: self._Drag(e, "Right"), + on_right_pan_end=lambda e: self._EndDrag(), + on_tertiary_tap_up=self._OnMiddleClick, + hover_interval=20, + drag_interval=16, expand=True, ) super().__init__( @@ -266,10 +312,12 @@ class GraphView(ft.Container): ft.Row([ ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: OnClose()), ft.Text("Graph", weight=ft.FontWeight.BOLD), - self.ScopePicker, Legend, ft.Container(expand=True), self.CountText, + self.ScopePicker, ft.Row(LegendItems, spacing=4, tight=True), + ft.Container(expand=True), self.CountText, ], spacing=12), ft.Divider(height=1, color=Palette.Surface1), - ft.Stack([self.Viewer, ft.Container(self.Status, alignment=ft.Alignment.CENTER)], expand=True), + ft.Stack([self.Surface, self.StatusBox], expand=True), + ft.Text(f"{Hint}, scroll to zoom, click a dot to open it.", size=11, color=Palette.Overlay0), ], spacing=8, expand=True), expand=True, bgcolor=Palette.Base, padding=12, ) @@ -285,14 +333,15 @@ class GraphView(ft.Container): def Reload(self) -> None: self.Generation += 1 Generation = self.Generation - Path = self.DirectoryPath if self.ScopePicker.value == "Directory" else "" + self.ScopePath = self.DirectoryPath if self.ScopePicker.value == "Directory" else "" self.Status.value = "Building the graph…" - self.Status.visible = True + self.StatusBox.visible = True self.BaseCanvas.shapes = [] self.LitCanvas.shapes = [] self.Layout = None + self.Hovered = None self.update() - self.page.run_thread(self._Build, Generation, Path) + self.page.run_thread(self._Build, Generation, self.ScopePath) def _Build(self, Generation: int, Path: str) -> None: try: @@ -304,13 +353,22 @@ class GraphView(ft.Container): return if Generation != self.Generation or self.Closed: return - Files = sum(1 for Node in Graph["Nodes"] if not Node["IsDirectory"]) - self.CountText.value = (f"{len(Graph['Nodes']) - Files} directories, {Files} files" - + (" (cut short: too many to show)" if Graph.get("Truncated") else "")) + Directories = sum(1 for Node in Graph["Nodes"] if Node["IsDirectory"]) + Files = len(Graph["Nodes"]) - Directories + Graph = FilterGraph(Graph, self.ShowDirectories) + Count = f"{Directories} directories, {Files} files" if self.ShowDirectories else f"{Files} files" + self.CountText.value = Count + (" (cut short: too many to show)" if Graph.get("Truncated") else "") self.CountText.update() + if not Graph["Nodes"]: + self.Status.value = "Nothing to show here." + self.Status.update() + return self.Layout = Layout(Graph) - self.Status.visible = False - self.Status.update() + Base = len(Path.split("/")) if Path else 0 + self.Colours = [DirectoryColour((len(Node["Id"].split("/")) if Node["Id"] else 0) - Base) if Node["IsDirectory"] + else Palette.White for Node in self.Layout.Nodes] + self.StatusBox.visible = False + self.StatusBox.update() # Settle in steps, drawing as it goes, like the web view's animation. while not self.Layout.Settled: for _ in range(TicksPerDraw): @@ -323,13 +381,13 @@ class GraphView(ft.Container): # --- Drawing ------------------------------------------------------------ def _Fit(self) -> None: - """Maps the layout onto the canvas, so the whole graph fits at zoom 1 - (the viewer's own zoom and pan go from there).""" + """Maps the layout onto the canvas, so the whole graph fits at zoom 1.""" Position = self.Layout.Position Low, High = Position.min(0), Position.max(0) Width, Height = self.Size Margin = 40 Scale = min((Width - 2 * Margin) / max(High[0] - Low[0], 1), (Height - 2 * Margin) / max(High[1] - Low[1], 1), 1.5) + Scale = max(Scale, 0.05) # the canvas can start out smaller than the margins Middle = (Low + High) / 2 self.Screen = (Position - Middle) * Scale + (Width / 2, Height / 2) Zoom = math.sqrt(Scale) @@ -345,8 +403,7 @@ class GraphView(ft.Container): def _Dot(self, Number: int) -> cv.Circle: X, Y = self.Screen[Number] - Colour = Palette.Mauve if self.Layout.Nodes[Number]["IsDirectory"] else Palette.White - return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=Colour)) + return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=self.Colours[Number])) def _Line(self, LinkNumber: int, Colour: str, Width: float = 1) -> cv.Line: A, B = self.Screen[self.Layout.Sources[LinkNumber]], self.Screen[self.Layout.Targets[LinkNumber]] @@ -391,26 +448,89 @@ class GraphView(ft.Container): self.LitCanvas.shapes = Shapes self.LitCanvas.update() + # --- View transform ----------------------------------------------------- + + def _ApplyView(self) -> None: + self.Plane.transform = ft.Transform( + matrix=ft.Matrix4.identity().translate(self.PanX, self.PanY).scale(self.Zoom, self.Zoom), + alignment=ft.Alignment.TOP_LEFT, + ) + self.Plane.update() + + def _ToCanvas(self, X: float, Y: float) -> tuple[float, float]: + return (X - self.PanX) / self.Zoom, (Y - self.PanY) / self.Zoom + + def _PanBy(self, DX: float, DY: float) -> None: + self.PanX += DX + self.PanY += DY + self._ApplyView() + # --- Input -------------------------------------------------------------- def _NodeAt(self, X: float, Y: float) -> Optional[int]: + """The dot under a point on the surface (screen coordinates).""" if self.Layout is None or not len(self.Screen): return None + X, Y = self._ToCanvas(X, Y) Distance = np.hypot(self.Screen[:, 0] - X, self.Screen[:, 1] - Y) Number = int(Distance.argmin()) - return Number if Distance[Number] <= max(6, self.Radius[Number] + 4) else None + Reach = max(6 / self.Zoom, self.Radius[Number] + 4 / self.Zoom) # at least 6 screen pixels + return Number if Distance[Number] <= Reach else None def _OnResize(self, Event) -> None: self.Size = (Event.width, Event.height) self._Draw() - def _OnHover(self, Event) -> None: - Number = self._NodeAt(Event.local_position.x, Event.local_position.y) + def _SetHovered(self, Number: Optional[int]) -> None: if Number != self.Hovered: self.Hovered = Number self._DrawHighlight() + def _OnHover(self, Event) -> None: + X, Y = Event.local_position.x, Event.local_position.y + if self.Grabbed and self.DragFrom is not None: + self._PanBy(X - self.DragFrom[0], Y - self.DragFrom[1]) + self.DragFrom = (X, Y) + return + self._SetHovered(self._NodeAt(X, Y)) + def _OnTap(self, Event) -> None: + if self.Grabbed: # a left click also lets go + self.Grabbed = False + self.DragFrom = None + return Number = self._NodeAt(Event.local_position.x, Event.local_position.y) if Number is not None: self.OnOpenNode(self.Layout.Nodes[Number]) + + def _OnMiddleClick(self, Event) -> None: + if self.PanButton != "Middle": + return + self.Grabbed = not self.Grabbed + self.DragFrom = (Event.local_position.x, Event.local_position.y) if self.Grabbed else None + self.Surface.mouse_cursor = ft.MouseCursor.MOVE if self.Grabbed else ft.MouseCursor.BASIC + self.Surface.update() + + def _StartDrag(self, Event, Button: str) -> None: + if Button == self.PanButton: + self.DragFrom = (Event.local_position.x, Event.local_position.y) + + def _Drag(self, Event, Button: str) -> None: + if Button != self.PanButton or self.DragFrom is None: + return + X, Y = Event.local_position.x, Event.local_position.y + self._PanBy(X - self.DragFrom[0], Y - self.DragFrom[1]) + self.DragFrom = (X, Y) + + def _EndDrag(self) -> None: + if not self.Grabbed: + self.DragFrom = None + + def _OnScroll(self, Event) -> None: + """Zooms around the pointer.""" + X, Y = Event.local_position.x, Event.local_position.y + CanvasX, CanvasY = self._ToCanvas(X, Y) + self.Zoom = min(8.0, max(0.2, self.Zoom * math.exp(-Event.scroll_delta.y * 0.0015))) + self.PanX = X - CanvasX * self.Zoom + self.PanY = Y - CanvasY * self.Zoom + self._ApplyView() diff --git a/Client/Settings.py b/Client/Settings.py index 4434966..bff08e9 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -117,6 +117,18 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego MenuButton = ChipButton("Remove from app menu" if MenuEntryPath.exists() else "Add to app menu", ToggleMenuEntry) MenuButton.visible = bool(AppImagePath) + # Graph. + GraphDirectoriesSwitch = ft.Switch(label="Show directories (off: only files and the links between them)", + value=Config.GraphShowDirectories) + PanButtonPicker = ft.RadioGroup( + ft.Column([ + ft.Radio(value="Left", label="Left"), + ft.Radio(value="Middle", label="Middle (click to grab, move, click again to let go)"), + ft.Radio(value="Right", label="Right"), + ], spacing=0), + value=Config.GraphPanButton, + ) + # Connection. ModePicker = ft.RadioGroup( ft.Row([ft.Radio(value="Local", label="Local files"), ft.Radio(value="Server", label="SaraView server")]), @@ -205,6 +217,16 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego ft.Divider(height=1, color=Palette.Surface1), OwnAppearance, ], spacing=12, scroll=ft.ScrollMode.AUTO), + "Graph": ft.Column([ + _Note("The graph view (Ctrl+G): directories in the heading colours by depth, files white, " + "joined to their directory and to the files that reference them."), + GraphDirectoriesSwitch, + ft.Divider(height=1, color=Palette.Surface1), + ft.Text("Pan with mouse button"), + PanButtonPicker, + _Note("Scroll zooms. Flet can't follow a held middle button, so Middle works like " + "middle-click autoscroll."), + ], spacing=12, scroll=ft.ScrollMode.AUTO), "Connection": ft.Column([ _Note("Browse a directory on this computer, or connect to a SaraView server."), ModePicker, LocalOptions, ServerOptions, @@ -240,6 +262,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego Config.ShowSidebar = SidebarSwitch.value Config.ShowFilesInTree = TreeFilesSwitch.value Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing + Config.GraphShowDirectories = GraphDirectoriesSwitch.value + Config.GraphPanButton = PanButtonPicker.value or Config.GraphPanButton Config.FontSize = _ReadInt(FontSizeField, Config.FontSize, 8, 32) Config.Mode = ModePicker.value or Config.Mode Config.LocalRoot = (LocalRootField.value or "").strip() or "~" diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index c1f2a70..6225b92 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -382,6 +382,8 @@ export default function App() { SetIsGraphOpen(false)} /> diff --git a/Frontend/src/Components/GraphPane.jsx b/Frontend/src/Components/GraphPane.jsx index c849175..1f20444 100644 --- a/Frontend/src/Components/GraphPane.jsx +++ b/Frontend/src/Components/GraphPane.jsx @@ -3,17 +3,23 @@ import React, { useEffect, useRef, useState } from "react"; import { FetchGraph } from "../Api.js"; // The graph view, like Obsidian's: every directory and file under a -// directory as a dot (directories Purple, files White), joined to its parent -// directory, and to every file that references it (Backend/Graph.py). +// directory as a dot (directories in the heading colours by depth, files +// White), joined to its parent directory, and to every file that references +// it (Backend/Graph.py). With directories turned off (Settings > Graph) only +// the files and the references between them are left. // The layout is a small force simulation: dots push each other apart and -// links pull them together until it settles. Drag the background to pan, -// scroll to zoom, drag a dot to move it, click one to open it. +// links pull them together until it settles. Pan with the button picked in +// Settings > Graph, scroll to zoom, drag a dot to move it, click one to open it. const ChildLength = 50; const ReferenceLength = 80; const Repulsion = 900; const CellSize = 160; // dots further apart than this don't push each other const LabelZoom = 0.7; // below this, only hovered labels are drawn +const ClickSlop = 4; // pixels a click may wander and still count as a click +// Directories by depth, like the headings: h1 Purple, h2 Blue, ... +const DepthRoles = ["Purple", "Blue", "Green", "Yellow", "Orange", "Red"]; +const ButtonNumbers = { Left: 0, Middle: 1, Right: 2 }; function ReadRole(Name) { return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim(); @@ -126,7 +132,7 @@ function Tick(Nodes, Links, Alpha) { } } -export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose }) { +export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, PanButton, OnOpenNode, OnClose }) { const [Scope, SetScope] = useState("Root"); const [Graph, SetGraph] = useState(null); const [Error, SetError] = useState(null); @@ -147,9 +153,11 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose if (!Graph) return undefined; const Canvas = CanvasRef.current; const Context = Canvas.getContext("2d"); - const Nodes = Graph.Nodes.map((Node) => ({ ...Node, Degree: 0 })); + const Nodes = Graph.Nodes.filter((Node) => ShowDirectories || !Node.IsDirectory).map((Node) => ({ ...Node, Degree: 0 })); const ById = new Map(Nodes.map((Node) => [Node.Id, Node])); - const Links = Graph.Links.filter((Link) => ById.has(Link.Source) && ById.has(Link.Target)).map((Link) => ({ + const Links = Graph.Links.filter( + (Link) => (ShowDirectories || Link.Kind === "Reference") && ById.has(Link.Source) && ById.has(Link.Target), + ).map((Link) => ({ ...Link, SourceNode: ById.get(Link.Source), TargetNode: ById.get(Link.Target), @@ -164,6 +172,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose for (const Node of Nodes) { Node.Radius = Node.IsDirectory ? 5 + Math.sqrt(Node.Degree) * 1.6 : 3 + Math.sqrt(Node.Degree) * 0.8; } + if (!Nodes.length) return undefined; InitialLayout(Nodes, Links); const Colours = { @@ -176,6 +185,13 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose Muted: ReadRole("TextMuted"), }; const FontFamily = ReadRole("FontFamily"); + const DepthColours = DepthRoles.map(ReadRole); + const BaseDepth = Graph.Path ? Graph.Path.split("/").length : 0; + for (const Node of Nodes) { + const Depth = (Node.Id ? Node.Id.split("/").length : 0) - BaseDepth; + Node.Colour = Node.IsDirectory ? DepthColours[Depth % DepthColours.length] : Colours.File; + } + const PanNumber = ButtonNumbers[PanButton] ?? 1; const State = { Zoom: 1, @@ -243,7 +259,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose if (X < -50 || Y < -50 || X > State.Width + 50 || Y > State.Height + 50) continue; const Radius = Math.max(2, Node.Radius * Math.sqrt(State.Zoom)); Context.globalAlpha = IsLit ? 1 : 0.2; - Context.fillStyle = Node.IsDirectory ? Colours.Directory : Colours.File; + Context.fillStyle = Node.Colour; Context.beginPath(); Context.arc(X, Y, Radius, 0, Math.PI * 2); Context.fill(); @@ -296,11 +312,15 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose const [X, Y] = LocalPoint(Event); const Node = NodeAt(X, Y); State.Moved = false; - if (Node) { + State.DownAt = [X, Y]; + if (Event.button !== 0 || Event.button === PanNumber) { + Event.preventDefault(); // no middle-click autoscroll or right-click menu + } + if (Event.button === 0 && Node) { + // Left on a dot: a click opens it, a drag moves it. State.Dragging = Node; Node.Pinned = true; - State.Alpha = Math.max(State.Alpha, 0.3); - } else { + } else if (Event.button === PanNumber) { State.Panning = [X, Y, State.PanX, State.PanY]; } } @@ -308,17 +328,19 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose function OnMouseMove(Event) { const [X, Y] = LocalPoint(Event); if (State.Dragging) { - const [WX, WY] = ToWorld(X, Y); - State.Dragging.x = WX; - State.Dragging.y = WY; - State.Moved = true; + State.Moved = State.Moved || Math.hypot(X - State.DownAt[0], Y - State.DownAt[1]) > ClickSlop; + if (State.Moved) { + const [WX, WY] = ToWorld(X, Y); + State.Dragging.x = WX; + State.Dragging.y = WY; + State.Alpha = Math.max(State.Alpha, 0.3); + } return; } if (State.Panning) { const [StartX, StartY, PanX, PanY] = State.Panning; State.PanX = PanX + X - StartX; State.PanY = PanY + Y - StartY; - State.Moved = State.Moved || Math.hypot(X - StartX, Y - StartY) > 3; return; } State.Hovered = NodeAt(X, Y); @@ -342,6 +364,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose State.PanY = Y - WY * State.Zoom; } + function OnContextMenu(Event) { + if (PanNumber === 2) Event.preventDefault(); + } + function OnLeave() { State.Hovered = null; OnMouseUp(); @@ -354,6 +380,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose Canvas.addEventListener("mousemove", OnMouseMove); window.addEventListener("mouseup", OnMouseUp); Canvas.addEventListener("mouseleave", OnLeave); + Canvas.addEventListener("contextmenu", OnContextMenu); Canvas.addEventListener("wheel", OnWheel, { passive: false }); State.Frame = requestAnimationFrame(Loop); @@ -364,9 +391,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose Canvas.removeEventListener("mousemove", OnMouseMove); window.removeEventListener("mouseup", OnMouseUp); Canvas.removeEventListener("mouseleave", OnLeave); + Canvas.removeEventListener("contextmenu", OnContextMenu); Canvas.removeEventListener("wheel", OnWheel); }; - }, [Graph]); + }, [Graph, ShowDirectories, PanButton]); const FileCount = Graph ? Graph.Nodes.filter((Node) => !Node.IsDirectory).length : 0; const DirectoryCount = Graph ? Graph.Nodes.length - FileCount : 0; @@ -382,13 +410,20 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose - ● Directory + {ShowDirectories && ( + <> + ● + ● + ● Directories by depth + + )} ● File ─ Reference {Graph && ( - {DirectoryCount} directories, {FileCount} files + {ShowDirectories ? `${DirectoryCount} directories, ` : ""} + {FileCount} files {Graph.Truncated ? " (cut short: too many to show)" : ""} )} @@ -396,6 +431,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose {Error &&
{Error}
} {!Graph && !Error &&
Building the graph…
} {Graph && } +
+ {PanButton === "Left" ? "Drag" : `${PanButton}-drag`} the background to pan, scroll to zoom, drag a dot to move it, + click one to open it. +
); } diff --git a/Frontend/src/Components/SettingsPanel.jsx b/Frontend/src/Components/SettingsPanel.jsx index 53b5966..e9743ca 100644 --- a/Frontend/src/Components/SettingsPanel.jsx +++ b/Frontend/src/Components/SettingsPanel.jsx @@ -401,6 +401,25 @@ export default function SettingsPanel({ +
+
Graph
+
+ + UpdateField("GraphShowDirectories", Event.target.checked)} + /> +
+
+ + +
+
+
UpdateField("ConfirmBeforeDelete", Event.target.checked)} /> diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index b3cb671..5be3044 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -600,6 +600,20 @@ img, color: var(--Purple); } +.GraphLegendDepth1 { + color: var(--Blue); +} + +.GraphLegendDepth2 { + color: var(--Green); +} + +.GraphHint { + padding: 4px 16px 8px; + color: var(--TextMuted); + font-size: 12px; +} + .GraphLegendFile { color: var(--White); } diff --git a/README.md b/README.md index ba802d0..c81a4e8 100644 --- a/README.md +++ b/README.md @@ -298,10 +298,14 @@ videos and other files open in their default program (Open Externally). **Graph** (the toolbar's graph button or Ctrl+G here, the Graph button at the top of the web view's sidebar) shows everything under a root, or under -the current directory, as dots like Obsidian's graph: directories purple, -files white, each joined to its parent directory and to the files that -reference it (Markdown links and images, `[[wiki links]]` and portals). -Hover a dot to light up its links, click it to open it. +the current directory, as dots like Obsidian's graph: directories in the +heading colours by depth (purple, blue, green, ...), files white, each +joined to its parent directory and to the files that reference it (Markdown +links and images, `[[wiki links]]` and portals). Hover a dot to light up its +links, click it to open it, scroll to zoom. Settings > Graph picks the mouse +button that pans (Middle by default) and can hide directories, leaving only +files and the links between them. In the client, Flet can't follow a held +middle button, so Middle there is click to grab, move, click to let go. Rename takes a `/` to move, like the web view (`../Archive/Notes.md`). Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes,