mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Client: Mermaid diagrams, drawn by the server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
292614c696
commit
f99b227cc3
9 files changed
+439
-9
No files matched your search
@@ -10,3 +10,4 @@ Frontend/dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
Backend/Vendor/
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Mermaid diagrams as images, for the desktop client (POST /api/diagram).
|
||||||
|
|
||||||
|
The web view draws ```mermaid blocks itself (Frontend/src/Mermaid.js), but
|
||||||
|
Flet can't, so the client asks the server instead. This draws them the same
|
||||||
|
way, with the same Mermaid, in a headless Chromium (Playwright) that's
|
||||||
|
started on the first diagram and kept for the next ones, and returns a PNG
|
||||||
|
at twice the size (sharp on HiDPI screens; the client shows it at half).
|
||||||
|
|
||||||
|
Mermaid comes from the frontend build (the Dockerfile copies it to
|
||||||
|
Backend/Vendor/; for local development, Frontend/node_modules after
|
||||||
|
`npm install`), the font from Frontend's public/Fonts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
from collections import OrderedDict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from Settings import DefaultThemesDirectory, PaletteRoles
|
||||||
|
|
||||||
|
ApplicationDirectory = Path(__file__).resolve().parent
|
||||||
|
ProjectDirectory = ApplicationDirectory.parent
|
||||||
|
|
||||||
|
MermaidCandidates = [
|
||||||
|
ApplicationDirectory / "Vendor" / "mermaid.min.js",
|
||||||
|
ProjectDirectory / "Frontend" / "node_modules" / "mermaid" / "dist" / "mermaid.min.js",
|
||||||
|
]
|
||||||
|
FontCandidates = [
|
||||||
|
ProjectDirectory / "Frontend" / "dist" / "Fonts" / "FSEX302.ttf",
|
||||||
|
ProjectDirectory / "Frontend" / "public" / "Fonts" / "FSEX302.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
MaxSourceLength = 20_000
|
||||||
|
RenderTimeout = 20 # seconds
|
||||||
|
CacheSize = 200
|
||||||
|
HexColour = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramError(Exception):
|
||||||
|
"""The diagram itself is wrong (Mermaid's message)."""
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramsUnavailable(Exception):
|
||||||
|
"""This server can't draw diagrams (no Chromium or no Mermaid)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _FirstExisting(Candidates: list[Path]) -> Optional[Path]:
|
||||||
|
return next((Candidate for Candidate in Candidates if Candidate.is_file()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _DefaultPalette() -> dict[str, str]:
|
||||||
|
with (DefaultThemesDirectory / "CatppuccinMocha.toml").open("rb") as ThemeFile:
|
||||||
|
return tomllib.load(ThemeFile).get("Palette", {})
|
||||||
|
|
||||||
|
|
||||||
|
def CleanPalette(Palette: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""The default theme, overridden by whichever roles were sent as #rrggbb."""
|
||||||
|
Clean = _DefaultPalette()
|
||||||
|
for Role in PaletteRoles:
|
||||||
|
Value = Palette.get(Role)
|
||||||
|
if isinstance(Value, str) and HexColour.match(Value):
|
||||||
|
Clean[Role] = Value
|
||||||
|
return Clean
|
||||||
|
|
||||||
|
|
||||||
|
def ThemeVariables(Palette: dict[str, str], FontSize: int) -> dict[str, Any]:
|
||||||
|
"""Mermaid's colours from the theme's roles: the same mapping as the web
|
||||||
|
view's Frontend/src/Mermaid.js, so a diagram looks the same in both."""
|
||||||
|
Role = Palette.get
|
||||||
|
Background = Role("Background")
|
||||||
|
Red, Green, Blue = (int(Background[Index:Index + 2], 16) for Index in (1, 3, 5))
|
||||||
|
Luminance = (0.299 * Red + 0.587 * Green + 0.114 * Blue) / 255
|
||||||
|
return {
|
||||||
|
"darkMode": Luminance < 0.5,
|
||||||
|
"background": Background,
|
||||||
|
"fontFamily": '"Fixedsys Excelsior", "Cascadia Mono", monospace',
|
||||||
|
"fontSize": f"{FontSize}px",
|
||||||
|
"textColor": Role("Text"),
|
||||||
|
"lineColor": Role("TextMuted"),
|
||||||
|
"primaryColor": Role("SurfaceAlt"),
|
||||||
|
"primaryTextColor": Role("Text"),
|
||||||
|
"primaryBorderColor": Role("Purple"),
|
||||||
|
"secondaryColor": Role("Surface"),
|
||||||
|
"secondaryTextColor": Role("Text"),
|
||||||
|
"secondaryBorderColor": Role("Blue"),
|
||||||
|
"tertiaryColor": Role("Background"),
|
||||||
|
"tertiaryTextColor": Role("Text"),
|
||||||
|
"tertiaryBorderColor": Role("Border"),
|
||||||
|
"mainBkg": Role("SurfaceAlt"),
|
||||||
|
"nodeBorder": Role("Purple"),
|
||||||
|
"clusterBkg": Role("Surface"),
|
||||||
|
"clusterBorder": Role("Border"),
|
||||||
|
"edgeLabelBackground": Role("Surface"),
|
||||||
|
"titleColor": Role("Text"),
|
||||||
|
"noteBkgColor": Role("Surface"),
|
||||||
|
"noteTextColor": Role("Text"),
|
||||||
|
"noteBorderColor": Role("Yellow"),
|
||||||
|
"actorBkg": Role("SurfaceAlt"),
|
||||||
|
"actorBorder": Role("Purple"),
|
||||||
|
"actorTextColor": Role("Text"),
|
||||||
|
"actorLineColor": Role("TextMuted"),
|
||||||
|
"signalColor": Role("Text"),
|
||||||
|
"signalTextColor": Role("Text"),
|
||||||
|
"labelBoxBkgColor": Role("SurfaceAlt"),
|
||||||
|
"labelBoxBorderColor": Role("Purple"),
|
||||||
|
"labelTextColor": Role("Text"),
|
||||||
|
"loopTextColor": Role("Text"),
|
||||||
|
"activationBkgColor": Role("Surface"),
|
||||||
|
"activationBorderColor": Role("Blue"),
|
||||||
|
"pie1": Role("Blue"),
|
||||||
|
"pie2": Role("Purple"),
|
||||||
|
"pie3": Role("Green"),
|
||||||
|
"pie4": Role("Yellow"),
|
||||||
|
"pie5": Role("Orange"),
|
||||||
|
"pie6": Role("Red"),
|
||||||
|
"pieTitleTextColor": Role("Text"),
|
||||||
|
"pieSectionTextColor": Role("Black"),
|
||||||
|
"pieLegendTextColor": Role("Text"),
|
||||||
|
"pieStrokeColor": Background,
|
||||||
|
"pieOuterStrokeColor": Role("Border"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Draws #Out from the source; the page has Mermaid and the font loaded.
|
||||||
|
RenderScript = """async ({ Source, Variables, Id }) => {
|
||||||
|
await document.fonts.ready;
|
||||||
|
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", themeVariables: Variables });
|
||||||
|
try {
|
||||||
|
await mermaid.parse(Source);
|
||||||
|
} catch (Error) {
|
||||||
|
return { Error: String(Error.message || Error) };
|
||||||
|
}
|
||||||
|
const { svg } = await mermaid.render(Id, Source);
|
||||||
|
const Out = document.getElementById("Out");
|
||||||
|
Out.innerHTML = svg;
|
||||||
|
// Mermaid shrinks diagrams to fit their container; here they get their own size.
|
||||||
|
const Picture = Out.querySelector("svg");
|
||||||
|
const Box = Picture.viewBox.baseVal;
|
||||||
|
Picture.style.maxWidth = "none";
|
||||||
|
Picture.setAttribute("width", Box.width);
|
||||||
|
Picture.setAttribute("height", Box.height);
|
||||||
|
return {};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
class _Renderer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.Lock = asyncio.Lock()
|
||||||
|
self.Playwright = None
|
||||||
|
self.Browser = None
|
||||||
|
self.Page = None
|
||||||
|
self.PageDirectory: Optional[tempfile.TemporaryDirectory] = None
|
||||||
|
self.Count = 0
|
||||||
|
self.Cache: OrderedDict[str, bytes] = OrderedDict()
|
||||||
|
|
||||||
|
async def _EnsurePage(self) -> None:
|
||||||
|
if self.Page is not None and not self.Page.is_closed():
|
||||||
|
return
|
||||||
|
Mermaid = _FirstExisting(MermaidCandidates)
|
||||||
|
if Mermaid is None:
|
||||||
|
raise DiagramsUnavailable("Diagrams aren't set up on this server (Mermaid is missing).")
|
||||||
|
try:
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
except ImportError:
|
||||||
|
raise DiagramsUnavailable("Diagrams aren't set up on this server (Playwright is missing).")
|
||||||
|
|
||||||
|
if self.Browser is None or not self.Browser.is_connected():
|
||||||
|
try:
|
||||||
|
self.Playwright = self.Playwright or await async_playwright().start()
|
||||||
|
self.Browser = await self.Playwright.chromium.launch()
|
||||||
|
except Exception as Error:
|
||||||
|
raise DiagramsUnavailable(f"Diagrams aren't set up on this server ({Error}).")
|
||||||
|
|
||||||
|
Font = _FirstExisting(FontCandidates)
|
||||||
|
FontFace = f'@font-face {{ font-family: "Fixedsys Excelsior"; src: url("{Font.as_uri()}"); }}' if Font else ""
|
||||||
|
self.PageDirectory = self.PageDirectory or tempfile.TemporaryDirectory(prefix="SaraViewDiagrams")
|
||||||
|
PagePath = Path(self.PageDirectory.name) / "Render.html"
|
||||||
|
PagePath.write_text(
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
f"<style>{FontFace} body {{ margin: 0; }} #Out {{ display: inline-block; padding: 8px; }}</style>"
|
||||||
|
f"<script src='{Mermaid.as_uri()}'></script></head><body><div id='Out'></div></body></html>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
Context = await self.Browser.new_context(device_scale_factor=2, viewport={"width": 1600, "height": 1200})
|
||||||
|
self.Page = await Context.new_page()
|
||||||
|
# Only the page's own files: a diagram can't make the server fetch anything.
|
||||||
|
async def OnlyLocalFiles(Route) -> None:
|
||||||
|
if Route.request.url.startswith("file:"):
|
||||||
|
await Route.continue_()
|
||||||
|
else:
|
||||||
|
await Route.abort()
|
||||||
|
|
||||||
|
await self.Page.route("**/*", OnlyLocalFiles)
|
||||||
|
await self.Page.goto(PagePath.as_uri())
|
||||||
|
|
||||||
|
async def Render(self, Source: str, Palette: dict[str, Any], FontSize: int) -> bytes:
|
||||||
|
if not Source.strip():
|
||||||
|
raise DiagramError("The diagram is empty.")
|
||||||
|
if len(Source) > MaxSourceLength:
|
||||||
|
raise DiagramError(f"The diagram is too long (over {MaxSourceLength} characters).")
|
||||||
|
Variables = ThemeVariables(CleanPalette(Palette), max(8, min(int(FontSize), 40)))
|
||||||
|
Key = hashlib.sha256(json.dumps([Source, Variables], sort_keys=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
async with self.Lock:
|
||||||
|
if Key in self.Cache:
|
||||||
|
self.Cache.move_to_end(Key)
|
||||||
|
return self.Cache[Key]
|
||||||
|
await self._EnsurePage()
|
||||||
|
self.Count += 1
|
||||||
|
try:
|
||||||
|
Result = await asyncio.wait_for(
|
||||||
|
self.Page.evaluate(RenderScript, {"Source": Source, "Variables": Variables, "Id": f"Diagram{self.Count}"}),
|
||||||
|
RenderTimeout,
|
||||||
|
)
|
||||||
|
if Result.get("Error"):
|
||||||
|
raise DiagramError(Result["Error"])
|
||||||
|
Png = await self.Page.locator("#Out").screenshot(omit_background=True, timeout=RenderTimeout * 1000)
|
||||||
|
except DiagramError:
|
||||||
|
raise
|
||||||
|
except Exception as Error:
|
||||||
|
# A stuck or crashed page: start a fresh one next time.
|
||||||
|
if self.Page is not None:
|
||||||
|
await self.Page.context.close()
|
||||||
|
self.Page = None
|
||||||
|
raise DiagramError(f"Couldn't draw the diagram ({Error}).")
|
||||||
|
|
||||||
|
self.Cache[Key] = Png
|
||||||
|
if len(self.Cache) > CacheSize:
|
||||||
|
self.Cache.popitem(last=False)
|
||||||
|
return Png
|
||||||
|
|
||||||
|
|
||||||
|
_SharedRenderer = _Renderer()
|
||||||
|
|
||||||
|
|
||||||
|
async def RenderDiagram(Source: str, Palette: dict[str, Any], FontSize: int) -> bytes:
|
||||||
|
return await _SharedRenderer.Render(Source, Palette, FontSize)
|
||||||
@@ -29,6 +29,7 @@ from Auth import (
|
|||||||
from BookReader import ReadBookForFile
|
from BookReader import ReadBookForFile
|
||||||
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
|
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
|
||||||
from Core import NormalizeRelativePath, ResolvePath
|
from Core import NormalizeRelativePath, ResolvePath
|
||||||
|
from Diagrams import DiagramError, DiagramsUnavailable, RenderDiagram
|
||||||
from Embeds import BuildOgTags
|
from Embeds import BuildOgTags
|
||||||
from Editor import WriteTextFile
|
from Editor import WriteTextFile
|
||||||
from Explorer import (
|
from Explorer import (
|
||||||
@@ -833,6 +834,25 @@ def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurren
|
|||||||
return SaveSettings(CurrentUser["Username"], NewSettings)
|
return SaveSettings(CurrentUser["Username"], NewSettings)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramRequest(BaseModel):
|
||||||
|
Source: str
|
||||||
|
Palette: dict[str, str] = {}
|
||||||
|
FontSize: int = 14
|
||||||
|
|
||||||
|
|
||||||
|
# A ```mermaid block as a PNG, for the desktop client (the web view draws its
|
||||||
|
# own). Palette is the client's theme by role, so it matches the client.
|
||||||
|
@Application.post("/api/diagram")
|
||||||
|
async def PostDiagramEndpoint(Body: DiagramRequest, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> Response:
|
||||||
|
try:
|
||||||
|
Png = await RenderDiagram(Body.Source, Body.Palette, Body.FontSize)
|
||||||
|
except DiagramError as Error:
|
||||||
|
raise HTTPException(status_code=422, detail=str(Error))
|
||||||
|
except DiagramsUnavailable as Error:
|
||||||
|
raise HTTPException(status_code=503, detail=str(Error))
|
||||||
|
return Response(content=Png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@Application.get("/api/themes")
|
@Application.get("/api/themes")
|
||||||
def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
|
def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
|
||||||
return ListThemeNames()
|
return ListThemeNames()
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
tomli-w==1.0.0
|
tomli-w==1.0.0
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
|
playwright==1.62.0
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Mermaid diagrams (```mermaid blocks), like the web view's. Flet can't draw
|
||||||
|
them, so the server does (POST /api/diagram, Backend/Diagrams.py) and they're
|
||||||
|
shown as pictures in this client's theme. In a local folder (no server), or
|
||||||
|
if the server can't draw them, they stay code blocks.
|
||||||
|
|
||||||
|
A DiagramBlock shows its code straight away and swaps in the picture once it
|
||||||
|
arrives (fetched on a background thread), so opening a note never waits on
|
||||||
|
the server. Pictures are kept for the session, so the live editor's
|
||||||
|
re-renders don't ask again."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
|
||||||
|
from Sources import SourceError
|
||||||
|
|
||||||
|
DiagramFence = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*mermaid[ \t]*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Per session: pictures and Mermaid's complaints by (source, font size), and
|
||||||
|
# whether the server said it can't draw diagrams at all.
|
||||||
|
Pictures: dict[tuple[str, int], bytes] = {}
|
||||||
|
Problems: dict[tuple[str, int], str] = {}
|
||||||
|
ServerCantDraw = False
|
||||||
|
|
||||||
|
|
||||||
|
def DiagramSource(BlockText: str) -> Optional[str]:
|
||||||
|
"""The diagram inside a ```mermaid code block, or None for other blocks."""
|
||||||
|
Lines = BlockText.split("\n")
|
||||||
|
Match = DiagramFence.match(Lines[0])
|
||||||
|
if not Match:
|
||||||
|
return None
|
||||||
|
Fence = Match.group(1)
|
||||||
|
Body = Lines[1:]
|
||||||
|
if Body:
|
||||||
|
Last = Body[-1].strip()
|
||||||
|
if Last.startswith(Fence) and not Last.strip(Fence[0]):
|
||||||
|
Body = Body[:-1]
|
||||||
|
return "\n".join(Body)
|
||||||
|
|
||||||
|
|
||||||
|
def ThemePalette() -> dict[str, str]:
|
||||||
|
"""This client's theme by the server's role names (see Core.Palette)."""
|
||||||
|
return {
|
||||||
|
"Background": Palette.Base, "Surface": Palette.Mantle, "SurfaceAlt": Palette.Surface0,
|
||||||
|
"Border": Palette.Surface1, "Text": Palette.Text, "TextMuted": Palette.Overlay0,
|
||||||
|
"Black": Palette.Crust, "White": Palette.White, "Red": Palette.Red, "Orange": Palette.Peach,
|
||||||
|
"Yellow": Palette.Yellow, "Green": Palette.Green, "Blue": Palette.Blue, "Purple": Palette.Mauve,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def CanDraw(TheApp) -> bool:
|
||||||
|
return TheApp.Source.Kind == "Server" and not ServerCantDraw
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramBlock(ft.Container):
|
||||||
|
def __init__(self, TheApp, Source: str, FontSize: int):
|
||||||
|
self.TheApp = TheApp
|
||||||
|
self.Source = Source
|
||||||
|
self.FontSize = FontSize
|
||||||
|
self.Key = (Source, FontSize)
|
||||||
|
self.Mounted = False
|
||||||
|
self.Fetching = False
|
||||||
|
super().__init__(content=self._Content())
|
||||||
|
|
||||||
|
def _Code(self) -> ft.Control:
|
||||||
|
return ft.Markdown(
|
||||||
|
value=f"```\n{self.Source}\n```",
|
||||||
|
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||||
|
md_style_sheet=MarkdownSheet(self.FontSize),
|
||||||
|
code_theme=MarkdownCodeTheme(),
|
||||||
|
code_style_sheet=CodeStyleSheet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _Content(self) -> ft.Control:
|
||||||
|
Picture = Pictures.get(self.Key)
|
||||||
|
if Picture is not None:
|
||||||
|
# The server draws at twice the size; shown at half, it's sharp on HiDPI.
|
||||||
|
Width, Height = struct.unpack(">II", Picture[16:24])
|
||||||
|
return ft.Row([ft.Image(src=Picture, width=Width / 2, height=Height / 2)],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO)
|
||||||
|
Problem = Problems.get(self.Key)
|
||||||
|
if Problem is not None:
|
||||||
|
return ft.Column([self._Code(), ft.Text(f"Diagram error: {Problem}", color=Palette.Red,
|
||||||
|
size=self.FontSize - 2, selectable=True)], spacing=4)
|
||||||
|
return self._Code()
|
||||||
|
|
||||||
|
def did_mount(self) -> None:
|
||||||
|
self.Mounted = True
|
||||||
|
if self.Key not in Pictures and self.Key not in Problems and not self.Fetching and CanDraw(self.TheApp):
|
||||||
|
self.Fetching = True
|
||||||
|
self.page.run_thread(self._Fetch)
|
||||||
|
|
||||||
|
def will_unmount(self) -> None:
|
||||||
|
self.Mounted = False
|
||||||
|
|
||||||
|
def _Fetch(self) -> None:
|
||||||
|
global ServerCantDraw
|
||||||
|
try:
|
||||||
|
Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize)
|
||||||
|
except SourceError as Error:
|
||||||
|
if Error.Status == 422:
|
||||||
|
Problems[self.Key] = str(Error)
|
||||||
|
elif Error.Status in (404, 503):
|
||||||
|
ServerCantDraw = True # an older server, or one without Chromium: stay code blocks
|
||||||
|
finally:
|
||||||
|
self.Fetching = False
|
||||||
|
if self.Mounted:
|
||||||
|
self.content = self._Content()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
|
||||||
|
def MaybeDiagram(TheApp, BlockText: str, FontSize: int) -> Optional[ft.Control]:
|
||||||
|
"""A DiagramBlock for a ```mermaid code block (LiveMarkdown's blocks), else None."""
|
||||||
|
Source = DiagramSource(BlockText)
|
||||||
|
if Source is None or not CanDraw(TheApp):
|
||||||
|
return None
|
||||||
|
return DiagramBlock(TheApp, Source, FontSize)
|
||||||
|
|
||||||
|
|
||||||
|
def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]:
|
||||||
|
"""The rendered view's controls: the Markdown, split around its diagrams."""
|
||||||
|
if not CanDraw(TheApp) or not any(DiagramFence.match(Line) for Line in Document.split("\n")):
|
||||||
|
return [MakeMarkdown(Document)]
|
||||||
|
from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module
|
||||||
|
|
||||||
|
Controls: list[ft.Control] = []
|
||||||
|
Position = 0
|
||||||
|
for Block in SplitBlocks(Document):
|
||||||
|
Source = DiagramSource(Document[Block.Start:Block.End]) if Block.Kind == "Code" else None
|
||||||
|
if Source is None:
|
||||||
|
continue
|
||||||
|
if Document[Position:Block.Start].strip():
|
||||||
|
Controls.append(MakeMarkdown(Document[Position:Block.Start]))
|
||||||
|
Controls.append(DiagramBlock(TheApp, Source, FontSize))
|
||||||
|
Position = Block.End
|
||||||
|
if Document[Position:].strip():
|
||||||
|
Controls.append(MakeMarkdown(Document[Position:]))
|
||||||
|
return Controls
|
||||||
@@ -6,7 +6,8 @@ another block, puts it back rendered.
|
|||||||
A block is what Markdown itself separates: a heading line, a fenced code
|
A block is what Markdown itself separates: a heading line, a fenced code
|
||||||
block, a horizontal rule, or a run of lines up to a blank line (a paragraph,
|
block, a horizontal rule, or a run of lines up to a blank line (a paragraph,
|
||||||
list, table or quote). The text between blocks (blank lines) is kept
|
list, table or quote). The text between blocks (blank lines) is kept
|
||||||
byte for byte, so editing one block never reformats the rest of the file."""
|
byte for byte, so editing one block never reformats the rest of the file.
|
||||||
|
A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py)."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -101,7 +102,8 @@ def SplitBlocks(Document: str) -> list[Block]:
|
|||||||
|
|
||||||
class LiveMarkdown(ft.Column):
|
class LiveMarkdown(ft.Column):
|
||||||
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
|
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
|
||||||
OnFocusChange: Callable[[bool], None], OnTapLink: Callable):
|
OnFocusChange: Callable[[bool], None], OnTapLink: Callable,
|
||||||
|
MakeDiagram: Optional[Callable[[str, int], Optional[ft.Control]]] = None):
|
||||||
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
|
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||||
self.Document = Document
|
self.Document = Document
|
||||||
@@ -109,6 +111,7 @@ class LiveMarkdown(ft.Column):
|
|||||||
self.OnChange = OnChange
|
self.OnChange = OnChange
|
||||||
self.OnFocusChange = OnFocusChange
|
self.OnFocusChange = OnFocusChange
|
||||||
self.OnTapLink = OnTapLink
|
self.OnTapLink = OnTapLink
|
||||||
|
self.MakeDiagram = MakeDiagram
|
||||||
self.Blocks: list[Block] = []
|
self.Blocks: list[Block] = []
|
||||||
self.BlockControls: list[ft.Control] = []
|
self.BlockControls: list[ft.Control] = []
|
||||||
# Rendered blocks by their text, reused when the layout is rebuilt so
|
# Rendered blocks by their text, reused when the layout is rebuilt so
|
||||||
@@ -217,8 +220,9 @@ class LiveMarkdown(ft.Column):
|
|||||||
# --- Rendering -------------------------------------------------------
|
# --- Rendering -------------------------------------------------------
|
||||||
|
|
||||||
def _Render(self, Text: str) -> ft.Control:
|
def _Render(self, Text: str) -> ft.Control:
|
||||||
|
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None
|
||||||
Rendered = ft.Container(
|
Rendered = ft.Container(
|
||||||
content=ft.Markdown(
|
content=Diagram or ft.Markdown(
|
||||||
value=Text,
|
value=Text,
|
||||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||||
md_style_sheet=MarkdownSheet(self.FontSize),
|
md_style_sheet=MarkdownSheet(self.FontSize),
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class Source:
|
|||||||
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
|
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
|
||||||
def Book(self, Root: str, RelativePath: str) -> dict: ...
|
def Book(self, Root: str, RelativePath: str) -> dict: ...
|
||||||
def Graph(self, Root: str, RelativePath: str) -> dict: ...
|
def Graph(self, Root: str, RelativePath: str) -> dict: ...
|
||||||
|
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def CanWrite(self) -> bool:
|
def CanWrite(self) -> bool:
|
||||||
@@ -275,6 +276,9 @@ class LocalSource(Source):
|
|||||||
|
|
||||||
return BuildLocalGraph(self, Root, RelativePath)
|
return BuildLocalGraph(self, Root, RelativePath)
|
||||||
|
|
||||||
|
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
|
||||||
|
raise SourceError("Diagrams are drawn by a SaraView server.")
|
||||||
|
|
||||||
|
|
||||||
class ServerSource(Source):
|
class ServerSource(Source):
|
||||||
"""A SaraView server, through the same /api endpoints the web frontend
|
"""A SaraView server, through the same /api endpoints the web frontend
|
||||||
@@ -464,6 +468,11 @@ class ServerSource(Source):
|
|||||||
# A big root takes the server a while to read through.
|
# A big root takes the server a while to read through.
|
||||||
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
|
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
|
||||||
|
|
||||||
|
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
|
||||||
|
"""A ```mermaid block as a PNG, drawn by the server (Diagrams.py)."""
|
||||||
|
Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize}
|
||||||
|
return self._Request("POST", "/diagram", json=Body).content
|
||||||
|
|
||||||
|
|
||||||
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
||||||
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
||||||
+13
-6
@@ -2,7 +2,7 @@
|
|||||||
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
|
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
|
||||||
|
|
||||||
- Markdown is rendered in View; the other text types are shown as
|
- Markdown is rendered in View; the other text types are shown as
|
||||||
highlighted code. Edit is a plain monospace field with an explicit Save
|
highlighted code. On a server, ```mermaid blocks show as diagrams (Diagrams.py). Edit is a plain monospace field with an explicit Save
|
||||||
(Ctrl+S). On a server, saving records line blame for you, like the web view.
|
(Ctrl+S). On a server, saving records line blame for you, like the web view.
|
||||||
- With Markdown editing set to Live (Settings > General), Markdown opens in
|
- With Markdown editing set to Live (Settings > General), Markdown opens in
|
||||||
LiveMarkdown instead: rendered, and click a block to edit it in place.
|
LiveMarkdown instead: rendered, and click a block to edit it in place.
|
||||||
@@ -20,6 +20,7 @@ import flet as ft
|
|||||||
|
|
||||||
from App import App
|
from App import App
|
||||||
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
|
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
|
||||||
|
from Diagrams import MaybeDiagram, WithDiagrams
|
||||||
from LiveMarkdown import LiveMarkdown
|
from LiveMarkdown import LiveMarkdown
|
||||||
from Sources import Entry, SourceError, TextFileTypes
|
from Sources import Entry, SourceError, TextFileTypes
|
||||||
|
|
||||||
@@ -121,7 +122,8 @@ class FileView(ft.Container):
|
|||||||
self.TextField.value = self.Content
|
self.TextField.value = self.Content
|
||||||
if self.IsLive:
|
if self.IsLive:
|
||||||
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
|
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
|
||||||
self.OnFocusChange, self._OnTapLink)
|
self.OnFocusChange, self._OnTapLink,
|
||||||
|
MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size))
|
||||||
return self.Live
|
return self.Live
|
||||||
return self._RenderedView()
|
return self._RenderedView()
|
||||||
if self.FileType == "image":
|
if self.FileType == "image":
|
||||||
@@ -133,9 +135,11 @@ class FileView(ft.Container):
|
|||||||
|
|
||||||
def _RenderedView(self) -> ft.Control:
|
def _RenderedView(self) -> ft.Control:
|
||||||
Text = self.TextField.value or ""
|
Text = self.TextField.value or ""
|
||||||
|
Views: Optional[list[ft.Control]] = None
|
||||||
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
|
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
|
||||||
View: ft.Control = ft.Markdown(
|
def MakeMarkdown(Part: str) -> ft.Control:
|
||||||
value=Text,
|
return ft.Markdown(
|
||||||
|
value=Part,
|
||||||
selectable=True,
|
selectable=True,
|
||||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||||
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
|
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
|
||||||
@@ -143,6 +147,9 @@ class FileView(ft.Container):
|
|||||||
code_style_sheet=CodeStyleSheet(),
|
code_style_sheet=CodeStyleSheet(),
|
||||||
on_tap_link=self._OnTapLink,
|
on_tap_link=self._OnTapLink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Views = WithDiagrams(self.TheApp, Text, self.TheApp.Config.FontSize, MakeMarkdown)
|
||||||
|
View: ft.Control = Views[0]
|
||||||
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
|
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
|
||||||
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
|
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
|
||||||
View = ft.Markdown(
|
View = ft.Markdown(
|
||||||
@@ -156,8 +163,8 @@ class FileView(ft.Container):
|
|||||||
else:
|
else:
|
||||||
View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize)
|
View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize)
|
||||||
if not Text:
|
if not Text:
|
||||||
View = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True)
|
View, Views = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True), None
|
||||||
return ft.Column([View], scroll=ft.ScrollMode.AUTO, expand=True)
|
return ft.Column(Views or [View], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
def _InfoView(self) -> ft.Control:
|
def _InfoView(self) -> ft.Control:
|
||||||
Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ FROM python:3.12-slim
|
|||||||
WORKDIR /App
|
WORKDIR /App
|
||||||
COPY Backend/requirements.txt ./
|
COPY Backend/requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
# Headless Chromium for drawing the desktop client's diagrams (Backend/Diagrams.py).
|
||||||
|
RUN playwright install --with-deps --only-shell chromium
|
||||||
COPY Backend/ ./Backend/
|
COPY Backend/ ./Backend/
|
||||||
COPY --from=FrontendBuild /Frontend/dist ./Frontend/dist
|
COPY --from=FrontendBuild /Frontend/dist ./Frontend/dist
|
||||||
|
# The same Mermaid the web view uses, for Backend/Diagrams.py.
|
||||||
|
COPY --from=FrontendBuild /Frontend/node_modules/mermaid/dist/mermaid.min.js ./Backend/Vendor/mermaid.min.js
|
||||||
|
|
||||||
WORKDIR /App/Backend
|
WORKDIR /App/Backend
|
||||||
EXPOSE 8420
|
EXPOSE 8420
|
||||||
|
|||||||
Reference in new issue
Block a user