mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Graph colours by depth, pan button and directory settings
This commit is contained in:
1 parent
57a07874ee
commit
d97f82beb2
10 files changed
+288
-58
No files matched your search
@@ -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"
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+154
-34
@@ -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()
|
||||
@@ -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 "~"
|
||||
|
||||
@@ -382,6 +382,8 @@ export default function App() {
|
||||
<GraphPane
|
||||
RootName={ActiveRoot}
|
||||
DirectoryPath={CurrentDirectoryPath}
|
||||
ShowDirectories={Settings ? Settings.GraphShowDirectories : true}
|
||||
PanButton={Settings ? Settings.GraphPanButton : "Middle"}
|
||||
OnOpenNode={HandleOpenGraphNode}
|
||||
OnClose={() => SetIsGraphOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
</option>
|
||||
</select>
|
||||
<span className="GraphLegend">
|
||||
<span className="GraphLegendDirectory">●</span> Directory
|
||||
{ShowDirectories && (
|
||||
<>
|
||||
<span className="GraphLegendDirectory">●</span>
|
||||
<span className="GraphLegendDepth1">●</span>
|
||||
<span className="GraphLegendDepth2">●</span> Directories by depth
|
||||
</>
|
||||
)}
|
||||
<span className="GraphLegendFile">●</span> File
|
||||
<span className="GraphLegendReference">─</span> Reference
|
||||
</span>
|
||||
{Graph && (
|
||||
<span className="GraphCount">
|
||||
{DirectoryCount} directories, {FileCount} files
|
||||
{ShowDirectories ? `${DirectoryCount} directories, ` : ""}
|
||||
{FileCount} files
|
||||
{Graph.Truncated ? " (cut short: too many to show)" : ""}
|
||||
</span>
|
||||
)}
|
||||
@@ -396,6 +431,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
{Error && <div className="PreviewEmpty">{Error}</div>}
|
||||
{!Graph && !Error && <div className="PreviewEmpty">Building the graph…</div>}
|
||||
{Graph && <canvas ref={CanvasRef} className="GraphCanvas" />}
|
||||
<div className="GraphHint">
|
||||
{PanButton === "Left" ? "Drag" : `${PanButton}-drag`} the background to pan, scroll to zoom, drag a dot to move it,
|
||||
click one to open it.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -401,6 +401,25 @@ export default function SettingsPanel({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Graph</div>
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show directories (off: only files and the links between them)</label>
|
||||
<Switch
|
||||
Checked={Settings.GraphShowDirectories}
|
||||
OnChange={(Event) => UpdateField("GraphShowDirectories", Event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Pan with mouse button</label>
|
||||
<select value={Settings.GraphPanButton} onChange={(Event) => UpdateField("GraphPanButton", Event.target.value)}>
|
||||
<option value="Left">Left</option>
|
||||
<option value="Middle">Middle</option>
|
||||
<option value="Right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Confirm before delete</label>
|
||||
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user