mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Add graph view
This commit is contained in:
1 parent
f851ecd820
commit
57a07874ee
12 files changed
+1152
-2
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
⚙
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user