Add graph view

This commit is contained in:
sarah committed 2026-10-09 09:16:38 +02:00
1 parent f851ecd820
commit 57a07874ee
12 files changed
+1152 -2

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
"""The graph view's data (like Obsidian's graph): every directory and file
under one directory, joined to its parent directory, plus a link from a
file to every file or directory it references.
References are read from Markdown and text files:
- Markdown links and images: [Text](Other.md), ![](Images/Cat.png)
- Wiki links: [[Other]], [[Other|Shown]], ![[Cat.png]] (matched by name,
like Obsidian, preferring the nearest one)
- Portals: {Path ***Name}, {Path head 3}, {Path '12'}
Listing follows the same rules as /api/tree (hidden files, .sv_ notes,
locks), so the graph never shows what the file list wouldn't. The desktop
client builds the same graph for local files (Client/Graph.py)."""
from __future__ import annotations
import re
from urllib.parse import unquote
from Core import NormalizeRelativePath, ResolvePath
from Explorer import ListDirectory
from Locks import IsPathAccessible
from Preview import ReadTextFile
from VarFunctions import MarkerPattern
# Past this, the graph is cut short (and says so) rather than taking ages
# to build and draw.
MaxNodes = 2000
# Bigger files aren't read for references.
MaxReadBytes = 1_000_000
ReferenceFileTypes = {"markdown", "text"}
FencedCodePattern = re.compile(r"^ {0,3}(```|~~~).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)
MarkdownLinkPattern = re.compile(r"!?\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
WikiLinkPattern = re.compile(r"!?\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
ExternalPattern = re.compile(r"^([a-z][a-z0-9+.-]*:|//|#)", re.IGNORECASE)
def FindReferences(Content: str) -> list[tuple[str, str]]:
"""("Path", relative path) and ("Name", wiki name) references in a file.
Code blocks are skipped, since links in them are examples."""
Content = FencedCodePattern.sub("", Content)
Found: list[tuple[str, str]] = []
for Match in MarkdownLinkPattern.finditer(Content):
Target = Match.group(1)
if ExternalPattern.match(Target):
continue
Target = unquote(Target.split("#", 1)[0].split("?", 1)[0])
if Target:
Found.append(("Path", Target))
for Match in WikiLinkPattern.finditer(Content):
Found.append(("Name", Match.group(1).strip()))
for Match in MarkerPattern.finditer(Content):
Target = Match.group("CrossPath") or Match.group("PipePath") or Match.group("LinePath")
if Target:
Found.append(("Path", Target))
return Found
def ResolveReferences(
References: list[tuple[str, str]], FileId: str, Ids: set[str], ByName: dict[str, list[str]]
) -> set[str]:
"""Node ids (paths relative to the root) that a file's references point to."""
Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else ""
Targets: set[str] = set()
for Kind, Target in References:
if Kind == "Path":
Base = "" if Target.startswith("/") else Directory
Resolved = NormalizeRelativePath(Base, Target)
if Resolved in Ids:
Targets.add(Resolved)
continue
# Wiki link: a path ("Sub/Note"), else a name anywhere, nearest first.
for Candidate in (Target, f"{Target}.md"):
for Base in (Directory, ""):
Resolved = NormalizeRelativePath(Base, Candidate)
if Resolved in Ids:
Targets.add(Resolved)
break
else:
continue
break
else:
Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), [])
if Matches:
Targets.add(min(Matches, key=lambda Id: (_Distance(Directory, Id), Id)))
Targets.discard(FileId)
return Targets
def _Distance(Directory: str, Id: str) -> int:
"""How many directories apart a file in Directory and Id are."""
Here = Directory.split("/") if Directory else []
There = Id.split("/")[:-1]
Shared = 0
for A, B in zip(Here, There):
if A != B:
break
Shared += 1
return len(Here) + len(There) - 2 * Shared
def IndexByName(Nodes: list[dict]) -> dict[str, list[str]]:
"""Files by lowercased name and by name without its extension, for wiki links."""
ByName: dict[str, list[str]] = {}
for Node in Nodes:
if Node["IsDirectory"]:
continue
Name = Node["Name"].lower()
ByName.setdefault(Name, []).append(Node["Id"])
if "." in Name:
ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"])
return ByName
def BuildGraph(
Root: str, Path: str, ShowHidden: bool, ShowSvFiles: bool, Username: str | None, UserGroups: list[str]
) -> dict:
Path = NormalizeRelativePath("", Path)
Nodes: list[dict] = [{"Id": Path, "Name": Path.rsplit("/", 1)[-1] if Path else Root,
"IsDirectory": True, "FileType": None}]
Links: list[dict] = []
Truncated = False
Pending = [Path]
while Pending and not Truncated:
Directory = Pending.pop(0)
try:
Entries = ListDirectory(Root, Directory, ShowHidden, ShowSvFiles)
except (ValueError, NotADirectoryError, FileNotFoundError, PermissionError):
continue
for Entry in Entries:
Id = f"{Directory}/{Entry['Name']}" if Directory else Entry["Name"]
if not IsPathAccessible(Root, Id, Username, UserGroups):
continue
if len(Nodes) >= MaxNodes:
Truncated = True
break
Nodes.append({"Id": Id, "Name": Entry["Name"], "IsDirectory": Entry["IsDirectory"],
"FileType": Entry.get("FileType")})
Links.append({"Source": Directory, "Target": Id, "Kind": "Child"})
if Entry["IsDirectory"]:
Pending.append(Id)
Ids = {Node["Id"] for Node in Nodes}
ByName = IndexByName(Nodes)
for Node in Nodes:
if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes:
continue
try:
FilePath = ResolvePath(Root, Node["Id"])
if FilePath.stat().st_size > MaxReadBytes:
continue
Content = ReadTextFile(FilePath)
except (ValueError, OSError, UnicodeDecodeError):
continue
for Target in sorted(ResolveReferences(FindReferences(Content), Node["Id"], Ids, ByName)):
Links.append({"Source": Node["Id"], "Target": Target, "Kind": "Reference"})
return {"Root": Root, "Path": Path, "Nodes": Nodes, "Links": Links, "Truncated": Truncated}
+22
View File
@@ -60,6 +60,7 @@ from Locks import (
RequirePathAccessible,
SetLock,
)
from Graph import BuildGraph
from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes
from Settings import (
DefaultSettings,
@@ -406,6 +407,27 @@ def GetTreeEndpoint(
]
@Application.get("/api/graph")
def GetGraphEndpoint(
Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest)
) -> dict:
"""Directories, files and the links between them under Path (see Graph.py)."""
RequirePathAccessible(Root, Path, CurrentUser)
UserSettings = DefaultSettings if CurrentUser["Username"] is None else LoadSettings(CurrentUser["Username"])
try:
return BuildGraph(
Root,
Path,
UserSettings.get("ShowHiddenFolders", False),
UserSettings.get("ShowSvFiles", False),
CurrentUser["Username"],
CurrentUser.get("Groups", []),
)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
@Application.post("/api/note")
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
+41
View File
@@ -13,6 +13,7 @@ import flet as ft
from App import App
from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Graph import GraphView
from Viewer import FileView
DoubleClickWindow = 0.4 # seconds
@@ -41,6 +42,7 @@ class Browser(ft.Column):
self.BackStack: list[tuple[Optional[str], str]] = []
self.ForwardStack: list[tuple[Optional[str], str]] = []
self.OpenView: Optional[FileView] = None
self.GraphView: Optional[GraphView] = None
self.ShortcutsPaused = False # a text field has focus
self.LastClickName: Optional[str] = None
self.LastClickTime = 0.0
@@ -71,6 +73,8 @@ class Browser(ft.Column):
self.NewFileButton,
self.NewDirectoryButton,
self.UploadButton,
ft.IconButton(icon=ft.Icons.HUB_OUTLINED, icon_color=Palette.Mauve, tooltip="Graph (Ctrl+G)",
on_click=lambda e: self.ShowGraph()),
ft.IconButton(icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Refresh (Ctrl+R)",
on_click=lambda e: self.Refresh()),
self.ConnectionButton,
@@ -168,6 +172,7 @@ class Browser(ft.Column):
self.Root, self.Path = Root, RelativePath
self.SelectedName = None
self.OpenView = None
self.GraphView = None
self.ShortcutsPaused = False
self._LoadEntries()
self._ShowList()
@@ -194,6 +199,9 @@ class Browser(ft.Column):
def Refresh(self) -> None:
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()
return
self._LoadEntries()
self._ShowList()
@@ -358,6 +366,33 @@ class Browser(ft.Column):
self.EntryRows[RowName].update()
self.SelectedName = Name
def ShowGraph(self) -> None:
"""The graph of the root being browsed, in place of the file list."""
if self.OpenView is not None:
self.OpenView.RequestClose(Then=self.ShowGraph)
return
Root = self.Root
if Root is None:
Roots = self._Roots()
if not Roots:
return
Root = Roots[0]
self.GraphView = GraphView(self.TheApp, Root, self.Path if Root == self.Root else "",
OnOpenNode=lambda Node: self._OpenGraphNode(Root, Node), OnClose=self.CloseGraph)
self.FileArea.content = self.GraphView
self.FileArea.update()
def CloseGraph(self) -> None:
self.GraphView = None
self._ShowList()
def _OpenGraphNode(self, Root: str, Node: dict) -> None:
if Node["IsDirectory"]:
self.NavigateTo(Root, Node["Id"])
else:
self.GraphView = None
self.OpenFile(Root, Node["Id"])
def OpenFile(self, Root: str, RelativePath: str) -> None:
"""Opens a file from anywhere (the sidebar tree): goes to its directory
first, so Back and Esc land in the list it's in."""
@@ -626,6 +661,12 @@ class Browser(ft.Column):
if not View.HandleEscape():
View.RequestClose()
return
if Event.key == "Escape" and self.GraphView is not None:
self.CloseGraph()
return
if Event.key == "G" and Event.ctrl and not self.ShortcutsPaused:
self.CloseGraph() if self.GraphView is not None else self.ShowGraph()
return
if self.ShortcutsPaused:
return
+416
View File
@@ -0,0 +1,416 @@
"""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.
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."""
import math
import re
import threading
from typing import Callable, Optional
from urllib.parse import unquote
import flet as ft
import flet.canvas as cv
import numpy as np
import Core
from App import App
from Core import Palette
from Sources import JoinPath, NormalizeRelativePath, SourceError
MaxNodes = 2000
MaxReadBytes = 1_000_000
ReferenceFileTypes = {"markdown", "text"}
# Same patterns as Backend/Graph.py.
FencedCodePattern = re.compile(r"^ {0,3}(```|~~~).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)
MarkdownLinkPattern = re.compile(r"!?\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
WikiLinkPattern = re.compile(r"!?\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
ExternalPattern = re.compile(r"^([a-z][a-z0-9+.-]*:|//|#)", re.IGNORECASE)
# The path part of a portal: {Path ***Name}, {Path head 3}, {Path '12'}
# (Backend/VarFunctions.py's MarkerPattern, just the parts with a path).
PortalPathPattern = re.compile(r"\{([^\s{}'*]+)\s+(?:\*\*\*[^\s*{}]+|(?:head|tail)\s+\d+|'\d+')[^{}]*\}")
# Layout, same as GraphPane.jsx.
ChildLength = 50
ReferenceLength = 80
Repulsion = 900
CellSize = 160
TicksPerDraw = 12
# --- Building the graph (local files) -------------------------------------
def FindReferences(Content: str) -> list[tuple[str, str]]:
Content = FencedCodePattern.sub("", Content)
Found: list[tuple[str, str]] = []
for Match in MarkdownLinkPattern.finditer(Content):
Target = Match.group(1)
if ExternalPattern.match(Target):
continue
Target = unquote(Target.split("#", 1)[0].split("?", 1)[0])
if Target:
Found.append(("Path", Target))
for Match in WikiLinkPattern.finditer(Content):
Found.append(("Name", Match.group(1).strip()))
for Match in PortalPathPattern.finditer(Content):
Found.append(("Path", Match.group(1)))
return Found
def _Distance(Directory: str, Id: str) -> int:
Here = Directory.split("/") if Directory else []
There = Id.split("/")[:-1]
Shared = 0
for A, B in zip(Here, There):
if A != B:
break
Shared += 1
return len(Here) + len(There) - 2 * Shared
def ResolveReferences(References: list[tuple[str, str]], FileId: str, Ids: set[str],
ByName: dict[str, list[str]]) -> set[str]:
Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else ""
Targets: set[str] = set()
for Kind, Target in References:
if Kind == "Path":
Resolved = NormalizeRelativePath("" if Target.startswith("/") else Directory, Target)
if Resolved in Ids:
Targets.add(Resolved)
continue
Found = next((NormalizeRelativePath(Base, Candidate)
for Candidate in (Target, f"{Target}.md") for Base in (Directory, "")
if NormalizeRelativePath(Base, Candidate) in Ids), None)
if Found is None:
Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), [])
if Matches:
Found = min(Matches, key=lambda Id: (_Distance(Directory, Id), Id))
if Found is not None:
Targets.add(Found)
Targets.discard(FileId)
return Targets
def BuildLocalGraph(Source, Root: str, Path: str) -> dict:
"""Backend/Graph.py's BuildGraph, through a Source's own listing (so the
same files are hidden as in the file list)."""
Nodes = [{"Id": Path, "Name": Path.rsplit("/", 1)[-1] if Path else Root, "IsDirectory": True, "FileType": None}]
Links: list[dict] = []
Truncated = False
Pending = [Path]
while Pending and not Truncated:
Directory = Pending.pop(0)
try:
Items = Source.List(Root, Directory)
except SourceError:
continue
for Item in Items:
if len(Nodes) >= MaxNodes:
Truncated = True
break
Id = JoinPath(Directory, Item.Name)
Nodes.append({"Id": Id, "Name": Item.Name, "IsDirectory": Item.IsDirectory, "FileType": Item.FileType})
Links.append({"Source": Directory, "Target": Id, "Kind": "Child"})
if Item.IsDirectory:
Pending.append(Id)
Ids = {Node["Id"] for Node in Nodes}
ByName: dict[str, list[str]] = {}
for Node in Nodes:
if not Node["IsDirectory"]:
Name = Node["Name"].lower()
ByName.setdefault(Name, []).append(Node["Id"])
if "." in Name:
ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"])
for Node in Nodes:
if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes:
continue
try:
if Source.LocalFile(Root, Node["Id"]).stat().st_size > MaxReadBytes:
continue
Content = Source.ReadText(Root, Node["Id"])
except (SourceError, OSError):
continue
for Target in sorted(ResolveReferences(FindReferences(Content), Node["Id"], Ids, ByName)):
Links.append({"Source": Node["Id"], "Target": Target, "Kind": "Reference"})
return {"Root": Root, "Path": Path, "Nodes": Nodes, "Links": Links, "Truncated": Truncated}
# --- Layout ----------------------------------------------------------------
class Layout:
def __init__(self, Graph: dict):
self.Nodes = Graph["Nodes"]
Index = {Node["Id"]: Number for Number, Node in enumerate(self.Nodes)}
Links = [Link for Link in Graph["Links"] if Link["Source"] in Index and Link["Target"] in Index]
self.Kinds = [Link["Kind"] for Link in Links]
self.Sources = np.array([Index[Link["Source"]] for Link in Links], dtype=int)
self.Targets = np.array([Index[Link["Target"]] for Link in Links], dtype=int)
IsChild = np.array([Kind == "Child" for Kind in self.Kinds], dtype=bool)
self.Rest = np.where(IsChild, ChildLength, ReferenceLength).astype(float)
self.Strength = np.where(IsChild, 0.08, 0.03)
Count = len(self.Nodes)
self.Degree = np.bincount(np.concatenate([self.Sources, self.Targets]), minlength=Count) if Links else np.zeros(Count)
self.Neighbours: list[set[int]] = [set() for _ in range(Count)]
for A, B in zip(self.Sources, self.Targets):
self.Neighbours[A].add(int(B))
self.Neighbours[B].add(int(A))
self.Position = self._Initial(Links, Index)
self.Velocity = np.zeros_like(self.Position)
self.Alpha = 1.0
def _Initial(self, Links: list[dict], Index: dict[str, int]) -> np.ndarray:
"""Each dot near its parent, spread around it (seeded, like the web view)."""
Random = np.random.default_rng(12345)
Position = np.zeros((len(self.Nodes), 2))
Placed = np.zeros(len(self.Nodes), dtype=bool)
Placed[0] = True
Children: dict[int, list[int]] = {}
for Link in Links:
if Link["Kind"] == "Child":
Children.setdefault(Index[Link["Source"]], []).append(Index[Link["Target"]])
Queue = [0]
while Queue:
Parent = Queue.pop(0)
Kids = Children.get(Parent, [])
Start = Random.random() * math.tau
for Number, Child in enumerate(Kids):
Angle = Start + Number / len(Kids) * math.tau
Distance = ChildLength * (1 + Random.random() * 0.5)
Position[Child] = Position[Parent] + (math.cos(Angle) * Distance, math.sin(Angle) * Distance)
Placed[Child] = True
Queue.append(Child)
Loose = ~Placed
Position[Loose] = (Random.random((Loose.sum(), 2)) - 0.5) * 200
return Position
@property
def Settled(self) -> bool:
return self.Alpha <= 0.005
def Tick(self) -> None:
Position, Velocity, Alpha = self.Position, self.Velocity, self.Alpha
Count = len(Position)
for Start in range(0, Count, 256): # in chunks, so memory stays small for big graphs
Delta = Position[Start:Start + 256, None, :] - Position[None, :, :]
DistanceSquared = np.maximum((Delta ** 2).sum(-1), 1.0)
Force = Repulsion * Alpha / DistanceSquared
Force[DistanceSquared > CellSize * CellSize] = 0
Velocity[Start:Start + 256] += (Delta * Force[..., None]).sum(1)
if len(self.Sources):
Delta = Position[self.Targets] - Position[self.Sources]
Distance = np.maximum(np.sqrt((Delta ** 2).sum(-1)), 1e-6)
Pull = ((Distance - self.Rest) / Distance * self.Strength * Alpha)[:, None] * Delta
np.add.at(Velocity, self.Sources, Pull)
np.add.at(Velocity, self.Targets, -Pull)
Velocity -= Position * 0.002 * Alpha
Velocity *= 0.6
Position += Velocity
self.Alpha *= 0.985
# --- View --------------------------------------------------------------------
class GraphView(ft.Container):
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.Layout: Optional[Layout] = None
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.Hovered: Optional[int] = None
self.Closed = False
self.Generation = 0 # bumps when the scope changes, so an old layout thread stops
self.ScopePicker = ft.Dropdown(
value="Root",
options=[ft.DropdownOption(key="Root", text="Whole root"),
ft.DropdownOption(key="Directory", text=f"This directory ({DirectoryPath})" if DirectoryPath else "This directory",
disabled=not DirectoryPath)],
dense=True, width=260, text_size=13,
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)
self.BaseCanvas = cv.Canvas(shapes=[], expand=True, on_resize=self._OnResize, resize_interval=100)
self.LitCanvas = cv.Canvas(shapes=[], expand=True)
self.Status = ft.Text("Building the graph…", color=Palette.Overlay0)
Surface = ft.GestureDetector(
content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True),
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),
expand=True,
)
super().__init__(
content=ft.Column([
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,
], spacing=12),
ft.Divider(height=1, color=Palette.Surface1),
ft.Stack([self.Viewer, ft.Container(self.Status, alignment=ft.Alignment.CENTER)], expand=True),
], spacing=8, expand=True),
expand=True, bgcolor=Palette.Base, padding=12,
)
def did_mount(self) -> None:
self.Reload()
def will_unmount(self) -> None:
self.Closed = True
# --- Data and layout ----------------------------------------------------
def Reload(self) -> None:
self.Generation += 1
Generation = self.Generation
Path = self.DirectoryPath if self.ScopePicker.value == "Directory" else ""
self.Status.value = "Building the graph…"
self.Status.visible = True
self.BaseCanvas.shapes = []
self.LitCanvas.shapes = []
self.Layout = None
self.update()
self.page.run_thread(self._Build, Generation, Path)
def _Build(self, Generation: int, Path: str) -> None:
try:
Graph = self.TheApp.Source.Graph(self.Root, Path)
except SourceError as Error:
if Generation == self.Generation and not self.Closed:
self.Status.value = f"Couldn't build the graph: {Error}"
self.Status.update()
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 ""))
self.CountText.update()
self.Layout = Layout(Graph)
self.Status.visible = False
self.Status.update()
# Settle in steps, drawing as it goes, like the web view's animation.
while not self.Layout.Settled:
for _ in range(TicksPerDraw):
self.Layout.Tick()
if Generation != self.Generation or self.Closed:
return
self._Draw()
self._Draw()
# --- 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)."""
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)
Middle = (Low + High) / 2
self.Screen = (Position - Middle) * Scale + (Width / 2, Height / 2)
Zoom = math.sqrt(Scale)
IsDirectory = np.array([Node["IsDirectory"] for Node in self.Layout.Nodes])
Degree = np.sqrt(self.Layout.Degree)
self.Radius = np.maximum(2, np.where(IsDirectory, 5 + Degree * 1.6, 3 + Degree * 0.8) * Zoom)
def _Label(self, Number: int, Colour: str) -> cv.Text:
X, Y = self.Screen[Number]
return cv.Text(X, Y + self.Radius[Number] + 2, self.Layout.Nodes[Number]["Name"],
style=ft.TextStyle(size=10, color=Colour, font_family=Core.ActiveFontFamily),
alignment=ft.Alignment.TOP_CENTER)
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))
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]]
return cv.Line(A[0], A[1], B[0], B[1], paint=ft.Paint(color=Colour, stroke_width=Width, style=ft.PaintingStyle.STROKE))
def _Draw(self) -> None:
if self.Layout is None or self.Closed:
return
self._Fit()
Shapes: list = []
for Number, Kind in enumerate(self.Layout.Kinds):
Shapes.append(self._Line(Number, Palette.Surface1 if Kind == "Child" else Palette.Blue))
Nodes = self.Layout.Nodes
ShowFileLabels = len(Nodes) <= 300
for Number, Node in enumerate(Nodes):
Shapes.append(self._Dot(Number))
if Node["IsDirectory"] or ShowFileLabels:
Shapes.append(self._Label(Number, Palette.Overlay0))
self.BaseCanvas.shapes = Shapes
self.BaseCanvas.update()
self._DrawHighlight()
def _DrawHighlight(self) -> None:
"""The hovered dot and its links, over a veil that dims the rest."""
Number = self.Hovered
if Number is None or self.Layout is None:
self.LitCanvas.shapes = []
else:
Width, Height = self.Size
Shapes: list = [cv.Rect(-4000, -4000, Width + 8000, Height + 8000,
paint=ft.Paint(color=ft.Colors.with_opacity(0.75, Palette.Base)))]
Lit = {Number} | self.Layout.Neighbours[Number]
for LinkNumber in range(len(self.Layout.Kinds)):
if Number in (self.Layout.Sources[LinkNumber], self.Layout.Targets[LinkNumber]):
Shapes.append(self._Line(LinkNumber, Palette.Mauve, 1.5))
for Other in Lit:
Shapes.append(self._Dot(Other))
Shapes.append(self._Label(Other, Palette.Text if Other == Number else Palette.Overlay0))
X, Y = self.Screen[Number]
Shapes.append(cv.Circle(X, Y, self.Radius[Number] + 3,
paint=ft.Paint(color=Palette.Mauve, stroke_width=2, style=ft.PaintingStyle.STROKE)))
self.LitCanvas.shapes = Shapes
self.LitCanvas.update()
# --- Input --------------------------------------------------------------
def _NodeAt(self, X: float, Y: float) -> Optional[int]:
if self.Layout is None or not len(self.Screen):
return None
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
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)
if Number != self.Hovered:
self.Hovered = Number
self._DrawHighlight()
def _OnTap(self, Event) -> None:
Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
if Number is not None:
self.OnOpenNode(self.Layout.Nodes[Number])
+11
View File
@@ -113,6 +113,7 @@ class Source:
def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress: Optional[Callable[[float], None]] = None) -> str: ...
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
def Book(self, Root: str, RelativePath: str) -> dict: ...
def Graph(self, Root: str, RelativePath: str) -> dict: ...
@property
def CanWrite(self) -> bool:
@@ -268,6 +269,12 @@ class LocalSource(Source):
def Book(self, Root: str, RelativePath: str) -> dict:
return {} # SaraTag tags are only read through a server for now
def Graph(self, Root: str, RelativePath: str) -> dict:
"""The graph view's data, built here the way the server builds it."""
from Graph import BuildLocalGraph # Graph.py imports this module
return BuildLocalGraph(self, Root, RelativePath)
class ServerSource(Source):
"""A SaraView server, through the same /api endpoints the web frontend
@@ -453,6 +460,10 @@ class ServerSource(Source):
except SourceError:
return {} # tags are extra; a file without them still opens
def Graph(self, Root: str, RelativePath: str) -> dict:
# A big root takes the server a while to read through.
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
+1
View File
@@ -3,3 +3,4 @@ flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first
tomlkit
httpx
send2trash
numpy
+5
View File
@@ -141,6 +141,11 @@ export function FetchTree(RootName, RelativePath) {
return FetchJson(`/tree?${Params}`);
}
export function FetchGraph(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/graph?${Params}`);
}
export function FetchFile(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchText(`/file?${Params}`);
+22 -2
View File
@@ -6,6 +6,7 @@ import PreviewPane from "./Components/PreviewPane.jsx";
import DirectoryPane from "./Components/DirectoryPane.jsx";
import TagPanel from "./Components/TagPanel.jsx";
import SettingsPanel from "./Components/SettingsPanel.jsx";
import GraphPane from "./Components/GraphPane.jsx";
import LoginScreen from "./Components/LoginScreen.jsx";
import ErrorToast from "./Components/ErrorToast.jsx";
import {
@@ -70,6 +71,7 @@ export default function App() {
const [SidebarKey, SetSidebarKey] = useState(0);
const [Settings, SetSettings] = useState(null);
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
const [IsGraphOpen, SetIsGraphOpen] = useState(false);
const [ErrorMessage, SetErrorMessage] = useState(null);
useEffect(() => {
@@ -254,6 +256,15 @@ export default function App() {
}
}
function HandleOpenGraphNode(Node) {
SetIsGraphOpen(false);
if (Node.IsDirectory) {
HandleBreadcrumbNavigate(Node.Id);
} else {
HandleSelectFile(Node.Id, Node.FileType);
}
}
function HandleEntrySelect(EntryPath, IsDirectory, FileType, Note) {
if (IsDirectory) {
OpenDirectoryNote(EntryPath, Note);
@@ -328,6 +339,8 @@ export default function App() {
OnEntryRenamed={HandleEntryRenamed}
OnEntryDeleted={HandleEntryDeleted}
OnOpenSettings={() => SetIsSettingsOpen(true)}
OnToggleGraph={() => SetIsGraphOpen(!IsGraphOpen)}
IsGraphOpen={IsGraphOpen}
OnRequestLogin={HandleRequestLogin}
OnEntryCreated={RefreshSidebar}
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
@@ -365,7 +378,14 @@ export default function App() {
</div>
)}
<div className="MainBody">
{ViewIsDirectory ? (
{IsGraphOpen ? (
<GraphPane
RootName={ActiveRoot}
DirectoryPath={CurrentDirectoryPath}
OnOpenNode={HandleOpenGraphNode}
OnClose={() => SetIsGraphOpen(false)}
/>
) : ViewIsDirectory ? (
<DirectoryPane
RootName={ActiveRoot}
DirectoryPath={ViewPath}
@@ -391,7 +411,7 @@ export default function App() {
IsGuest={IsGuest}
/>
)}
{!ViewIsDirectory && !ViewNoteFilePath && (
{!IsGraphOpen && !ViewIsDirectory && !ViewNoteFilePath && (
<TagPanel RootName={ActiveRoot} FilePath={ViewPath} />
)}
</div>
+401
View File
@@ -0,0 +1,401 @@
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).
// 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.
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
function ReadRole(Name) {
return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim();
}
// Starts each dot near its parent, spread around it, so the simulation
// begins close to a tree and settles quickly. Seeded, so the same directory
// always lays out the same way.
function InitialLayout(Nodes, Links) {
let Seed = 12345;
const Random = () => {
Seed = (Seed * 16807) % 2147483647;
return Seed / 2147483647;
};
const ById = new Map(Nodes.map((Node) => [Node.Id, Node]));
const ChildrenOf = new Map();
for (const Link of Links) {
if (Link.Kind !== "Child") continue;
if (!ChildrenOf.has(Link.Source)) ChildrenOf.set(Link.Source, []);
ChildrenOf.get(Link.Source).push(Link.Target);
}
const Root = Nodes[0];
Root.x = 0;
Root.y = 0;
const Queue = [Root];
while (Queue.length) {
const Parent = Queue.shift();
const Children = ChildrenOf.get(Parent.Id) || [];
const Start = Random() * Math.PI * 2;
Children.forEach((ChildId, Index) => {
const Child = ById.get(ChildId);
const Angle = Start + (Index / Children.length) * Math.PI * 2;
const Distance = ChildLength * (1 + Random() * 0.5);
Child.x = Parent.x + Math.cos(Angle) * Distance;
Child.y = Parent.y + Math.sin(Angle) * Distance;
Queue.push(Child);
});
}
for (const Node of Nodes) {
Node.vx = 0;
Node.vy = 0;
if (Node.x === undefined) {
Node.x = (Random() - 0.5) * 200;
Node.y = (Random() - 0.5) * 200;
}
}
}
function Tick(Nodes, Links, Alpha) {
// Repulsion between dots in the same or neighbouring grid cells.
const Grid = new Map();
for (const Node of Nodes) {
const Key = `${Math.floor(Node.x / CellSize)},${Math.floor(Node.y / CellSize)}`;
if (!Grid.has(Key)) Grid.set(Key, []);
Grid.get(Key).push(Node);
}
for (const Node of Nodes) {
const CellX = Math.floor(Node.x / CellSize);
const CellY = Math.floor(Node.y / CellSize);
for (let DX = -1; DX <= 1; DX++) {
for (let DY = -1; DY <= 1; DY++) {
const Others = Grid.get(`${CellX + DX},${CellY + DY}`);
if (!Others) continue;
for (const Other of Others) {
if (Other === Node) continue;
let X = Node.x - Other.x;
let Y = Node.y - Other.y;
let DistanceSquared = X * X + Y * Y;
if (DistanceSquared > CellSize * CellSize) continue;
if (DistanceSquared < 1) {
X = Math.random() - 0.5;
Y = Math.random() - 0.5;
DistanceSquared = 1;
}
const Force = (Repulsion * Alpha) / DistanceSquared;
Node.vx += X * Force;
Node.vy += Y * Force;
}
}
}
}
// Links pull towards their rest length.
for (const Link of Links) {
const A = Link.SourceNode;
const B = Link.TargetNode;
const X = B.x - A.x;
const Y = B.y - A.y;
const Distance = Math.sqrt(X * X + Y * Y) || 1;
const Rest = Link.Kind === "Child" ? ChildLength : ReferenceLength;
const Strength = Link.Kind === "Child" ? 0.08 : 0.03;
const Pull = ((Distance - Rest) / Distance) * Strength * Alpha;
A.vx += X * Pull;
A.vy += Y * Pull;
B.vx -= X * Pull;
B.vy -= Y * Pull;
}
// A gentle pull to the middle keeps separate clusters from drifting off.
for (const Node of Nodes) {
Node.vx -= Node.x * 0.002 * Alpha;
Node.vy -= Node.y * 0.002 * Alpha;
if (Node.Pinned) {
Node.vx = 0;
Node.vy = 0;
continue;
}
Node.vx *= 0.6;
Node.vy *= 0.6;
Node.x += Node.vx;
Node.y += Node.vy;
}
}
export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose }) {
const [Scope, SetScope] = useState("Root");
const [Graph, SetGraph] = useState(null);
const [Error, SetError] = useState(null);
const CanvasRef = useRef(null);
const StateRef = useRef(null);
const ScopePath = Scope === "Root" ? "" : DirectoryPath;
useEffect(() => {
SetGraph(null);
SetError(null);
FetchGraph(RootName, ScopePath)
.then(SetGraph)
.catch((Failure) => SetError(Failure.message));
}, [RootName, ScopePath]);
useEffect(() => {
if (!Graph) return undefined;
const Canvas = CanvasRef.current;
const Context = Canvas.getContext("2d");
const Nodes = Graph.Nodes.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) => ({
...Link,
SourceNode: ById.get(Link.Source),
TargetNode: ById.get(Link.Target),
}));
const Neighbours = new Map(Nodes.map((Node) => [Node, new Set()]));
for (const Link of Links) {
Link.SourceNode.Degree += 1;
Link.TargetNode.Degree += 1;
Neighbours.get(Link.SourceNode).add(Link.TargetNode);
Neighbours.get(Link.TargetNode).add(Link.SourceNode);
}
for (const Node of Nodes) {
Node.Radius = Node.IsDirectory ? 5 + Math.sqrt(Node.Degree) * 1.6 : 3 + Math.sqrt(Node.Degree) * 0.8;
}
InitialLayout(Nodes, Links);
const Colours = {
Background: ReadRole("Background"),
Directory: ReadRole("Purple"),
File: ReadRole("White"),
Child: ReadRole("Border"),
Reference: ReadRole("Blue"),
Text: ReadRole("Text"),
Muted: ReadRole("TextMuted"),
};
const FontFamily = ReadRole("FontFamily");
const State = {
Zoom: 1,
PanX: 0,
PanY: 0,
Alpha: 1,
Hovered: null,
Dragging: null,
Panning: null,
Moved: false,
Frame: 0,
Width: 0,
Height: 0,
};
StateRef.current = State;
function ToScreen(Node) {
return [Node.x * State.Zoom + State.PanX, Node.y * State.Zoom + State.PanY];
}
function ToWorld(X, Y) {
return [(X - State.PanX) / State.Zoom, (Y - State.PanY) / State.Zoom];
}
function Resize() {
const Rect = Canvas.getBoundingClientRect();
const Ratio = window.devicePixelRatio || 1;
const First = State.Width === 0;
State.Width = Rect.width;
State.Height = Rect.height;
Canvas.width = Math.round(Rect.width * Ratio);
Canvas.height = Math.round(Rect.height * Ratio);
Context.setTransform(Ratio, 0, 0, Ratio, 0, 0);
if (First) {
State.PanX = Rect.width / 2;
State.PanY = Rect.height / 2;
}
}
function Draw() {
const Focus = State.Dragging || State.Hovered;
const Lit = Focus ? Neighbours.get(Focus) : null;
Context.fillStyle = Colours.Background;
Context.fillRect(0, 0, State.Width, State.Height);
for (const Link of Links) {
const Touching = Focus && (Link.SourceNode === Focus || Link.TargetNode === Focus);
Context.globalAlpha = Focus && !Touching ? 0.15 : Link.Kind === "Child" ? 0.8 : 0.9;
Context.strokeStyle = Touching ? Colours.Directory : Link.Kind === "Child" ? Colours.Child : Colours.Reference;
Context.lineWidth = Touching ? 1.5 : 1;
const [X1, Y1] = ToScreen(Link.SourceNode);
const [X2, Y2] = ToScreen(Link.TargetNode);
Context.beginPath();
Context.moveTo(X1, Y1);
Context.lineTo(X2, Y2);
Context.stroke();
}
Context.font = `12px ${FontFamily}`;
Context.textAlign = "center";
Context.textBaseline = "top";
for (const Node of Nodes) {
const IsLit = !Focus || Node === Focus || Lit.has(Node);
const [X, Y] = ToScreen(Node);
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.beginPath();
Context.arc(X, Y, Radius, 0, Math.PI * 2);
Context.fill();
if (Node === Focus) {
Context.strokeStyle = Colours.Directory;
Context.lineWidth = 2;
Context.beginPath();
Context.arc(X, Y, Radius + 3, 0, Math.PI * 2);
Context.stroke();
}
const ShowLabel = Focus ? IsLit : State.Zoom >= LabelZoom || Node.IsDirectory;
if (ShowLabel) {
Context.fillStyle = Node === Focus ? Colours.Text : Colours.Muted;
Context.fillText(Node.Name, X, Y + Radius + 3);
}
}
Context.globalAlpha = 1;
}
function Loop() {
if (State.Alpha > 0.005 || State.Dragging) {
Tick(Nodes, Links, Math.max(State.Alpha, State.Dragging ? 0.1 : 0));
State.Alpha *= 0.985;
}
Draw();
State.Frame = requestAnimationFrame(Loop);
}
function NodeAt(X, Y) {
let Best = null;
let BestDistance = Infinity;
for (const Node of Nodes) {
const [NX, NY] = ToScreen(Node);
const Distance = Math.hypot(NX - X, NY - Y);
const Reach = Math.max(6, Node.Radius * Math.sqrt(State.Zoom) + 4);
if (Distance < Reach && Distance < BestDistance) {
Best = Node;
BestDistance = Distance;
}
}
return Best;
}
function LocalPoint(Event) {
const Rect = Canvas.getBoundingClientRect();
return [Event.clientX - Rect.left, Event.clientY - Rect.top];
}
function OnMouseDown(Event) {
const [X, Y] = LocalPoint(Event);
const Node = NodeAt(X, Y);
State.Moved = false;
if (Node) {
State.Dragging = Node;
Node.Pinned = true;
State.Alpha = Math.max(State.Alpha, 0.3);
} else {
State.Panning = [X, Y, State.PanX, State.PanY];
}
}
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;
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);
Canvas.style.cursor = State.Hovered ? "pointer" : "grab";
}
function OnMouseUp() {
const Clicked = State.Dragging && !State.Moved ? State.Dragging : null;
if (State.Dragging) State.Dragging.Pinned = false;
State.Dragging = null;
State.Panning = null;
if (Clicked) OnOpenNode(Clicked);
}
function OnWheel(Event) {
Event.preventDefault();
const [X, Y] = LocalPoint(Event);
const [WX, WY] = ToWorld(X, Y);
State.Zoom = Math.min(5, Math.max(0.1, State.Zoom * Math.exp(-Event.deltaY * 0.0015)));
State.PanX = X - WX * State.Zoom;
State.PanY = Y - WY * State.Zoom;
}
function OnLeave() {
State.Hovered = null;
OnMouseUp();
}
Resize();
const Observer = new ResizeObserver(Resize);
Observer.observe(Canvas);
Canvas.addEventListener("mousedown", OnMouseDown);
Canvas.addEventListener("mousemove", OnMouseMove);
window.addEventListener("mouseup", OnMouseUp);
Canvas.addEventListener("mouseleave", OnLeave);
Canvas.addEventListener("wheel", OnWheel, { passive: false });
State.Frame = requestAnimationFrame(Loop);
return () => {
cancelAnimationFrame(State.Frame);
Observer.disconnect();
Canvas.removeEventListener("mousedown", OnMouseDown);
Canvas.removeEventListener("mousemove", OnMouseMove);
window.removeEventListener("mouseup", OnMouseUp);
Canvas.removeEventListener("mouseleave", OnLeave);
Canvas.removeEventListener("wheel", OnWheel);
};
}, [Graph]);
const FileCount = Graph ? Graph.Nodes.filter((Node) => !Node.IsDirectory).length : 0;
const DirectoryCount = Graph ? Graph.Nodes.length - FileCount : 0;
return (
<div className="GraphPane">
<div className="PreviewToolbar">
<button onClick={OnClose}>Close</button>
<select value={Scope} onChange={(Event) => SetScope(Event.target.value)}>
<option value="Root">Whole root</option>
<option value="Directory" disabled={!DirectoryPath}>
This directory{DirectoryPath ? ` (${DirectoryPath})` : ""}
</option>
</select>
<span className="GraphLegend">
<span className="GraphLegendDirectory">●</span> Directory
<span className="GraphLegendFile">●</span> File
<span className="GraphLegendReference">─</span> Reference
</span>
{Graph && (
<span className="GraphCount">
{DirectoryCount} directories, {FileCount} files
{Graph.Truncated ? " (cut short: too many to show)" : ""}
</span>
)}
</div>
{Error && <div className="PreviewEmpty">{Error}</div>}
{!Graph && !Error && <div className="PreviewEmpty">Building the graph…</div>}
{Graph && <canvas ref={CanvasRef} className="GraphCanvas" />}
</div>
);
}
+9
View File
@@ -286,6 +286,8 @@ export default function Sidebar({
OnEntryRenamed,
OnEntryDeleted,
OnOpenSettings,
OnToggleGraph,
IsGraphOpen,
OnRequestLogin,
OnEntryCreated,
UseHyphenBullets,
@@ -450,6 +452,13 @@ export default function Sidebar({
</span>
<span className="SidebarHeaderRight">
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} OnRequestLogin={OnRequestLogin} />
<button
className={`SettingsButton${IsGraphOpen ? " SettingsButtonActive" : ""}`}
onClick={OnToggleGraph}
title="Graph view"
>
Graph
</button>
{!IsGuest && (
<button className="SettingsButton" onClick={OnOpenSettings}>
⚙
+57
View File
@@ -164,6 +164,10 @@ img,
background: var(--SurfaceAlt);
}
.SettingsButtonActive {
background: var(--SurfaceAlt);
}
.SidebarError {
padding: 10px 16px;
color: var(--Red);
@@ -551,6 +555,59 @@ img,
color: var(--Blue);
}
/* Graph view (GraphPane.jsx). The canvas reads its colours from the same
role variables. */
.GraphPane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.GraphPane .PreviewToolbar {
align-items: center;
flex-wrap: wrap;
}
.GraphPane select {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 3px 6px;
font-family: inherit;
}
.GraphCanvas {
flex: 1;
min-height: 0;
width: 100%;
display: block;
cursor: grab;
}
.GraphLegend,
.GraphCount {
color: var(--TextMuted);
font-size: 13px;
}
.GraphLegend span {
margin: 0 4px 0 10px;
}
.GraphLegendDirectory {
color: var(--Purple);
}
.GraphLegendFile {
color: var(--White);
}
.GraphLegendReference {
color: var(--Blue);
}
/* Live Markdown editing (LivePreview.js). The editor runs without a
CodeMirror theme here, so it's styled entirely from the roles. */
.LiveEditor .cm-editor {
+7
View File
@@ -296,6 +296,13 @@ it back), with Source (Ctrl+E) for the plain text; **Separate** keeps View
and Edit as two modes. Images show in the app;
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.
Rename takes a `/` to move, like the web view (`../Archive/Notes.md`).
Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes,
Ctrl+N / Ctrl+Shift+N new file / directory, Ctrl+R refreshes, Ctrl+B