Add live Markdown editing, colours and files in the tree

This commit is contained in:
sarah committed 2026-10-09 09:03:23 +02:00
1 parent 0eecfba224
commit f851ecd820
15 files changed
+1025 -58

No files matched your search

+3
View File
@@ -45,6 +45,9 @@ DefaultSettings: dict[str, Any] = {
"ConfirmBeforeDelete": True,
"VisibleInBlame": True,
"ShowBlameAvatars": False,
# "Live": Markdown is edited where it's shown rendered (Obsidian-style
# live preview). "Separate": rendered View and raw Edit are two modes.
"MarkdownEditing": "Live",
}
DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes"
+16 -10
View File
@@ -11,20 +11,12 @@ from typing import Callable, Optional
import flet as ft
from App import App
from Core import ChipButtonStyle, HumanSize, OpenExternally, Palette
from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Viewer import FileView
DoubleClickWindow = 0.4 # seconds
FileIcons = {
"markdown": ft.Icons.DESCRIPTION_OUTLINED,
"text": ft.Icons.ARTICLE_OUTLINED,
"config": ft.Icons.SETTINGS_OUTLINED,
"html": ft.Icons.CODE,
"image": ft.Icons.IMAGE_OUTLINED,
"video": ft.Icons.MOVIE_OUTLINED,
}
TypeLabels = {"markdown": "Markdown", "text": "Text", "config": "Config", "html": "HTML", "image": "Image", "video": "Video"}
@@ -366,6 +358,17 @@ class Browser(ft.Column):
self.EntryRows[RowName].update()
self.SelectedName = Name
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."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenFile(Root, RelativePath))
return
Directory = ParentPath(RelativePath)
if (Root, Directory) != (self.Root, self.Path) or self.OpenView is not None:
self.NavigateTo(Root, Directory)
self.OpenEntry(BaseName(RelativePath), InApp=True)
def OpenEntry(self, Name: str, InApp: Optional[bool] = None) -> None:
Item = self._Find(Name)
if Item is None:
@@ -388,11 +391,13 @@ class Browser(ft.Column):
)
self.FileArea.content = self.OpenView
self.FileArea.update()
self.OnPathChanged(Root, RelativePath, False) # highlights the file in the tree
def _CloseFile(self) -> None:
self.OpenView = None
self.ShortcutsPaused = False
self._ShowList()
self.OnPathChanged(self.Root, self.Path, False)
def _OpenExternally(self, Root: str, RelativePath: str) -> None:
try:
@@ -618,7 +623,8 @@ class Browser(ft.Column):
View.ToggleEdit()
return
if Event.key == "Escape" and View is not None:
View.RequestClose()
if not View.HandleEscape():
View.RequestClose()
return
if self.ShortcutsPaused:
return
+77
View File
@@ -61,6 +61,16 @@ DataHome = Home / ".local" / "share"
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
AssetsDir = BundleDir / "assets"
# Shared by the file list and the sidebar tree.
FileIcons = {
"markdown": ft.Icons.DESCRIPTION_OUTLINED,
"text": ft.Icons.ARTICLE_OUTLINED,
"config": ft.Icons.SETTINGS_OUTLINED,
"html": ft.Icons.CODE,
"image": ft.Icons.IMAGE_OUTLINED,
"video": ft.Icons.MOVIE_OUTLINED,
}
_ThemeRoleMap = {
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
@@ -151,7 +161,12 @@ class AppConfig:
ConfirmDelete: bool = True
ShowSidebar: bool = True
SidebarWidth: int = 240
ShowFilesInTree: bool = True # False: the sidebar tree shows directories only
FontSize: int = 14 # file list and viewer text
# "Separate": Markdown opens rendered, with Edit/View to switch to the raw
# text. "Live": rendered, and clicking a block shows its Markdown to edit
# in place (like Obsidian's live preview). Only affects Markdown.
MarkdownEditing: str = "Live"
# Double-click on a file: True opens it in SaraView's own viewer, False
# hands it to the system's default program. The menu always offers the other.
OpenFilesInApp: bool = True
@@ -360,6 +375,68 @@ def CodeStyleSheet() -> ft.MarkdownStyleSheet:
return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily))
def MarkdownSheet(FontSize: int) -> ft.MarkdownStyleSheet:
"""Rendered Markdown's colours, the same roles as the web view's
.MarkdownBody (Frontend/src/Styles/Theme.css): rainbow headings, coloured
bold/italic/code, a Purple bar on quotes."""
def Heading(Colour: str, Extra: int) -> ft.TextStyle:
return ft.TextStyle(size=FontSize + Extra, color=Colour, weight=ft.FontWeight.BOLD)
return ft.MarkdownStyleSheet(
p_text_style=ft.TextStyle(size=FontSize, color=Palette.Text),
h1_text_style=Heading(Palette.Mauve, 10),
h2_text_style=Heading(Palette.Blue, 7),
h3_text_style=Heading(Palette.Green, 4),
h4_text_style=Heading(Palette.Yellow, 2),
h5_text_style=Heading(Palette.Peach, 1),
h6_text_style=Heading(Palette.Red, 0),
strong_text_style=ft.TextStyle(color=Palette.Peach, weight=ft.FontWeight.BOLD),
em_text_style=ft.TextStyle(color=Palette.Yellow, italic=True),
del_text_style=ft.TextStyle(color=Palette.Overlay0, decoration=ft.TextDecoration.LINE_THROUGH),
a_text_style=ft.TextStyle(color=Palette.Blue, decoration=ft.TextDecoration.UNDERLINE),
code_text_style=ft.TextStyle(font_family=ActiveFontFamily, size=FontSize, color=Palette.Green, bgcolor=Palette.Surface0),
blockquote_text_style=ft.TextStyle(size=FontSize, color=Palette.Overlay0),
blockquote_decoration=ft.BoxDecoration(
bgcolor=Palette.Mantle, border=ft.Border(left=ft.BorderSide(3, Palette.Mauve)),
),
blockquote_padding=ft.Padding.only(left=12, top=6, right=8, bottom=6),
list_bullet_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve),
checkbox_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve),
table_head_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve, weight=ft.FontWeight.BOLD),
table_body_text_style=ft.TextStyle(size=FontSize, color=Palette.Text),
table_cells_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle),
codeblock_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle, border_radius=4),
horizontal_rule_decoration=ft.BoxDecoration(border=ft.Border(top=ft.BorderSide(1, Palette.Surface1))),
)
def MarkdownCodeTheme() -> ft.MarkdownCustomCodeTheme:
"""Syntax colours for code blocks from the palette roles (Catppuccin's
own mapping), in place of a fixed highlight.js theme that ignored the
active SaraSuite theme."""
def Style(Colour: str, **Extra) -> ft.TextStyle:
return ft.TextStyle(color=Colour, **Extra)
return ft.MarkdownCustomCodeTheme(
root=ft.TextStyle(color=Palette.Text, bgcolor=Palette.Mantle, font_family=ActiveFontFamily),
keyword=Style(Palette.Mauve), meta_keyword=Style(Palette.Mauve), doctag=Style(Palette.Mauve),
section=Style(Palette.Mauve, weight=ft.FontWeight.BOLD), bullet=Style(Palette.Mauve), tag=Style(Palette.Mauve),
string=Style(Palette.Green), meta_string=Style(Palette.Green), addition=Style(Palette.Green),
number=Style(Palette.Peach), literal=Style(Palette.Peach), regexp=Style(Palette.Peach), meta=Style(Palette.Peach),
built_in=Style(Palette.Yellow), builtin_name=Style(Palette.Yellow), type=Style(Palette.Yellow),
class_name=Style(Palette.Yellow), attr=Style(Palette.Yellow), attribute=Style(Palette.Yellow),
selector_class=Style(Palette.Yellow),
function=Style(Palette.Blue), title=Style(Palette.Blue), name=Style(Palette.Blue), link=Style(Palette.Blue),
selector_tag=Style(Palette.Blue), selector_id=Style(Palette.Blue),
variable=Style(Palette.Red), template_variable=Style(Palette.Red), symbol=Style(Palette.Red),
deletion=Style(Palette.Red),
comment=Style(Palette.Overlay0, italic=True), quote=Style(Palette.Overlay0, italic=True),
emphasis=ft.TextStyle(italic=True), strong=ft.TextStyle(weight=ft.FontWeight.BOLD),
)
def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton:
return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled)
+77 -32
View File
@@ -1,35 +1,68 @@
"""The sidebar's directory tree, built like SaraFem's Filetree: one node per
root, children loaded lazily on expand (every expand on a server is a
request). Clicking a directory opens it in the browser."""
"""The sidebar's tree, built like SaraFem's Filetree: one node per root,
children loaded lazily on expand (every expand on a server is a request).
Clicking a directory opens it in the browser; clicking a file opens it in
the viewer. Files can be hidden from the tree in Settings > General."""
from typing import Callable, Optional
from typing import Callable, Optional, Union
import flet as ft
from App import App
from Core import Palette
from Core import FileIcons, Palette
from Sources import JoinPath, SourceError
Location = tuple[str, str] # (Root, Path)
def _TreeLabel(Name: str, OnTap: Callable[[], None]) -> tuple[ft.Text, ft.Container]:
Label = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
Holder = ft.Container(content=ft.GestureDetector(content=Label, on_tap=lambda e: OnTap()), padding=4, border_radius=4)
return Label, Holder
def _SetHighlight(Label: ft.Text, Holder: ft.Container, IsSelected: bool) -> None:
Holder.bgcolor = Palette.Surface1 if IsSelected else None
Label.color = Palette.Mauve if IsSelected else Palette.Text
Holder.update()
class _FileLeaf:
"""A file in the tree: a plain row (no expand arrow) indented like a
child tile, so names line up with the directories beside it."""
def __init__(self, Root: str, RelativePath: str, FileType: Optional[str],
OnOpenFile: Callable[[str, str], None], Depth: int):
Name = RelativePath.rsplit("/", 1)[-1]
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: OnOpenFile(Root, RelativePath))
self.Tile = ft.Container(
content=ft.Row(
[ft.Icon(FileIcons.get(FileType or "", ft.Icons.INSERT_DRIVE_FILE_OUTLINED), size=16, color=Palette.Overlay0),
ft.Container(self.NameContainer, expand=True)],
spacing=12,
),
# Icon and name line up with a directory tile's at the same depth.
padding=ft.Padding.only(left=8 + Depth * 14, right=8, top=2, bottom=2),
)
def SetSelected(self, IsSelected: bool) -> None:
_SetHighlight(self.NameLabel, self.NameContainer, IsSelected)
class _FiletreeNode:
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None], Depth: int):
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None],
OnOpenFile: Callable[[str, str], None], Depth: int):
self.TheApp = TheApp
self.Root = Root
self.RelativePath = RelativePath
self.OnNavigate = OnNavigate
self.OnOpenFile = OnOpenFile
self.Depth = Depth
self.ChildrenLoaded = False
self.ChildNodes: dict[str, "_FiletreeNode"] = {}
self.FileLeaves: dict[str, _FileLeaf] = {}
Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root
self.NameLabel = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
self.NameContainer = ft.Container(
content=ft.GestureDetector(content=self.NameLabel, on_tap=lambda e: self.OnNavigate(self.Root, self.RelativePath)),
padding=4,
border_radius=4,
)
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: self.OnNavigate(self.Root, self.RelativePath))
self.Tile = ft.ExpansionTile(
title=self.NameContainer,
leading=ft.Icon(ft.Icons.STORAGE_OUTLINED if not RelativePath else ft.Icons.FOLDER_OUTLINED,
@@ -50,30 +83,35 @@ class _FiletreeNode:
def LoadChildren(self, Update: bool = True) -> None:
try:
Directories = [Item.Name for Item in self.TheApp.Source.List(self.Root, self.RelativePath) if Item.IsDirectory]
Items = self.TheApp.Source.List(self.Root, self.RelativePath)
except SourceError:
Directories = [] # locked, gone or unreachable: just no children
Items = [] # locked, gone or unreachable: just no children
self.ChildNodes = {}
for Name in Directories:
ChildPath = JoinPath(self.RelativePath, Name)
self.ChildNodes[Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate, self.Depth + 1)
self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()]
self.FileLeaves = {}
for Item in Items:
ChildPath = JoinPath(self.RelativePath, Item.Name)
if Item.IsDirectory:
self.ChildNodes[Item.Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate,
self.OnOpenFile, self.Depth + 1)
elif self.TheApp.Config.ShowFilesInTree:
self.FileLeaves[Item.Name] = _FileLeaf(self.Root, ChildPath, Item.FileType, self.OnOpenFile, self.Depth + 1)
# Directories first, then files, like the file list.
self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()] + [Leaf.Tile for Leaf in self.FileLeaves.values()]
self.ChildrenLoaded = True
if Update:
self.Tile.update()
def SetSelected(self, IsSelected: bool) -> None:
self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None
self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text
self.NameContainer.update()
_SetHighlight(self.NameLabel, self.NameContainer, IsSelected)
class Filetree(ft.Container):
def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None]):
def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None], OnOpenFile: Callable[[str, str], None]):
self.TheApp = TheApp
self.OnNavigate = OnNavigate
self.OnOpenFile = OnOpenFile
self.RootNodes: dict[str, _FiletreeNode] = {}
self.SelectedNode: Optional[_FiletreeNode] = None
self.SelectedNode: Optional[Union[_FiletreeNode, _FileLeaf]] = None
self.TreeList = ft.ListView(expand=True)
self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
super().__init__(
@@ -87,7 +125,7 @@ class Filetree(ft.Container):
def Reload(self) -> None:
"""Rebuilds the tree from the current source (after connecting,
logging in or out, or a change that moved directories around)."""
logging in or out, or a change that moved things around)."""
Source = self.TheApp.Source
self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe()
self.SelectedNode = None
@@ -98,7 +136,7 @@ class Filetree(ft.Container):
except SourceError:
Roots = []
Expanded = {Root: Node.Tile.expanded for Root, Node in self.RootNodes.items()}
self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, 0) for Root in Roots}
self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, self.OnOpenFile, 0) for Root in Roots}
for Root, Node in self.RootNodes.items():
# One root (always the case locally) starts open; otherwise keep what was open.
if Expanded.get(Root, len(Roots) == 1):
@@ -108,21 +146,28 @@ class Filetree(ft.Container):
self.update()
def RevealPath(self, Root: Optional[str], RelativePath: str) -> None:
"""Expand and highlight the tree down to a directory, loading as needed."""
"""Expand and highlight the tree down to a directory or file, loading
as needed."""
if self.SelectedNode:
self.SelectedNode.SetSelected(False)
self.SelectedNode = None
Node = self.RootNodes.get(Root or "")
if Node is None:
return
for Part in [Part for Part in RelativePath.split("/") if Part]:
Target: Union[_FiletreeNode, _FileLeaf] = Node
Parts = [Part for Part in RelativePath.split("/") if Part]
for Index, Part in enumerate(Parts):
if not Node.ChildrenLoaded:
Node.LoadChildren()
NextNode = Node.ChildNodes.get(Part)
if NextNode is None:
break # hidden or not a directory: highlight the closest parent
Leaf = Node.FileLeaves.get(Part) if Index == len(Parts) - 1 else None
if NextNode is None and Leaf is None:
break # hidden: highlight the closest parent
Node.Tile.expanded = True
Node.Tile.update()
Node = NextNode
Node.SetSelected(True)
self.SelectedNode = Node
if Leaf is not None:
Target = Leaf
break
Node = Target = NextNode
Target.SetSelected(True)
self.SelectedNode = Target
+268
View File
@@ -0,0 +1,268 @@
"""Live Markdown editing, like Obsidian's live preview: the file is shown
rendered, block by block, and clicking a block swaps it for its Markdown
(a heading shows its #s, a list its -s) to edit in place. Esc, or clicking
another block, puts it back rendered.
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,
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."""
import re
from dataclasses import dataclass
from typing import Callable, Optional
import flet as ft
import Core
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
FenceStart = re.compile(r"^ {0,3}(`{3,}|~{3,})")
HeadingLine = re.compile(r"^ {0,3}(#{1,6})(\s|$)")
RuleLine = re.compile(r"^ {0,3}([-*_])( *\1){2,} *$")
IndentedLine = re.compile(r"^( {2,}|\t)\S")
@dataclass
class Block:
Start: int # offsets into the document; Text = Document[Start:End]
End: int
Kind: str # "Heading", "Code", "Rule" or "Text"
StartLine: int
Level: int = 0 # heading level
def SplitBlocks(Document: str) -> list[Block]:
Lines = Document.split("\n")
Offsets: list[int] = []
Position = 0
for Line in Lines:
Offsets.append(Position)
Position += len(Line) + 1
def LineEnd(Index: int) -> int:
return Offsets[Index] + len(Lines[Index])
Blocks: list[Block] = []
Current: Optional[int] = None # first line of the open Text block
LastLine = -1 # last non-blank line of the open Text block
Fence: Optional[tuple[str, int]] = None # (character, length) of an open code fence
FenceFrom = 0
def CloseCurrent() -> None:
nonlocal Current
if Current is not None:
Blocks.append(Block(Offsets[Current], LineEnd(LastLine), "Text", Current))
Current = None
for Index, Line in enumerate(Lines):
if Fence is not None:
Match = FenceStart.match(Line)
if Match and Match.group(1)[0] == Fence[0] and len(Match.group(1)) >= Fence[1] and not Line.strip()[len(Match.group(1)):].strip():
Blocks.append(Block(Offsets[FenceFrom], LineEnd(Index), "Code", FenceFrom))
Fence = None
continue
if not Line.strip():
CloseCurrent()
continue
Match = FenceStart.match(Line)
if Match:
CloseCurrent()
Fence = (Match.group(1)[0], len(Match.group(1)))
FenceFrom = Index
continue
Match = HeadingLine.match(Line)
if Match:
CloseCurrent()
Blocks.append(Block(Offsets[Index], LineEnd(Index), "Heading", Index, len(Match.group(1))))
continue
Match = RuleLine.match(Line)
# "---" right under text is a setext heading's underline, not a rule.
if Match and (Current is None or Match.group(1) != "-"):
CloseCurrent()
Blocks.append(Block(Offsets[Index], LineEnd(Index), "Rule", Index))
continue
if Current is None:
Previous = Blocks[-1] if Blocks else None
# An indented line after blank lines continues a loose list
# (or a list item's second paragraph) rather than starting a code block.
if Previous is not None and Previous.Kind == "Text" and IndentedLine.match(Line):
Blocks.pop()
Current = Previous.StartLine
else:
Current = Index
LastLine = Index
CloseCurrent()
if Fence is not None: # never closed: runs to the last non-blank line
Last = max((I for I in range(FenceFrom, len(Lines)) if Lines[I].strip()), default=FenceFrom)
Blocks.append(Block(Offsets[FenceFrom], LineEnd(Last), "Code", FenceFrom))
return Blocks
class LiveMarkdown(ft.Column):
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
OnFocusChange: Callable[[bool], None], OnTapLink: Callable):
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.Document = Document
self.FontSize = FontSize
self.OnChange = OnChange
self.OnFocusChange = OnFocusChange
self.OnTapLink = OnTapLink
self.Blocks: list[Block] = []
self.BlockControls: list[ft.Control] = []
# Rendered blocks by their text, reused when the layout is rebuilt so
# only the edited part is rendered again.
self.RenderCache: dict[str, list[ft.Control]] = {}
self.EditingIndex: Optional[int] = None # len(Blocks) is the new block at the end
self.Field: Optional[ft.TextField] = None
self._Layout(Update=False)
# --- Text ------------------------------------------------------------
@property
def Text(self) -> str:
"""The whole document, including what's being typed in the open block."""
if self.EditingIndex is None or self.Field is None:
return self.Document
return self._Spliced(self.Field.value or "")
def SetText(self, Document: str, Update: bool = True) -> None:
self.EditingIndex = None
self.Field = None
self.Document = Document
self._Layout(Update)
def _Spliced(self, NewText: str) -> str:
"""The document with the open block replaced by NewText."""
Index = self.EditingIndex
Document = self.Document
if Index is None:
return Document
if Index == len(self.Blocks): # a new block after the last one
if not NewText.strip():
return Document
Base = Document
if Base and not Base.endswith("\n"):
Base += "\n"
if Base and not Base.endswith("\n\n"):
Base += "\n"
return Base + NewText.rstrip("\n") + "\n"
Current = self.Blocks[Index]
if NewText.strip():
return Document[:Current.Start] + NewText + Document[Current.End:]
# Emptied: take its blank lines with it, so no gap is left behind.
if Index + 1 < len(self.Blocks):
return Document[:Current.Start] + Document[self.Blocks[Index + 1].Start:]
if Index > 0:
return Document[:self.Blocks[Index - 1].End] + Document[Current.End:]
return Document[:Current.Start] + Document[Current.End:]
# --- Editing ---------------------------------------------------------
@property
def IsEditing(self) -> bool:
return self.EditingIndex is not None
def Commit(self) -> None:
"""Puts the open block back rendered (re-splitting the document, since
the edit may have made one block into several or merged it with the next)."""
if self.EditingIndex is None:
return
self.Document = self.Text
self.EditingIndex = None
self.Field = None
self.OnFocusChange(False)
self._Layout()
def _StartEditing(self, Index: int) -> None:
self.EditingIndex = Index
Item = self.Blocks[Index] if Index < len(self.Blocks) else None
Value = self.Document[Item.Start:Item.End] if Item else ""
Style = ft.TextStyle(size=self.FontSize, font_family=Core.ActiveFontFamily, color=Palette.Text)
if Item is not None and Item.Kind == "Heading":
# The heading stays its size and colour while its #s are showing.
Style = getattr(MarkdownSheet(self.FontSize), f"h{Item.Level}_text_style")
self.Field = ft.TextField(
value=Value,
multiline=True,
min_lines=1,
autofocus=True,
border=ft.InputBorder.NONE,
filled=True,
fill_color=Palette.Mantle,
content_padding=ft.Padding.symmetric(horizontal=8, vertical=6),
text_style=Style,
cursor_color=Palette.Mauve,
on_change=lambda e: self.OnChange(),
on_focus=lambda e: self.OnFocusChange(True),
)
self.OnFocusChange(True)
self._Layout()
def _OnBlockClicked(self, Control: ft.Control) -> None:
if self.EditingIndex is not None:
self.Commit() # Flutter doesn't unfocus a field when something else is clicked
# Find the block again in the (possibly rebuilt) layout.
for Index, Controls in enumerate(self.BlockControls):
if Controls is Control:
self._StartEditing(Index)
return
def _OnEndClicked(self) -> None:
if self.EditingIndex is not None:
self.Commit()
self._StartEditing(len(self.Blocks))
# --- Rendering -------------------------------------------------------
def _Render(self, Text: str) -> ft.Control:
Rendered = ft.Container(
content=ft.Markdown(
value=Text,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
on_tap_link=self.OnTapLink,
),
padding=ft.Padding.symmetric(horizontal=8, vertical=6),
border_radius=4,
)
Rendered.on_click = lambda e, C=Rendered: self._OnBlockClicked(C)
Rendered.on_hover = lambda e, C=Rendered: self._OnHover(C, e)
return Rendered
@staticmethod
def _OnHover(Control: ft.Container, Event) -> None:
Control.bgcolor = Palette.Mantle if str(Event.data).lower() == "true" else None
Control.update()
def _Layout(self, Update: bool = True) -> None:
self.Blocks = SplitBlocks(self.Document)
Cache = self.RenderCache
self.RenderCache = {}
self.BlockControls = []
for Index, Item in enumerate(self.Blocks):
if Index == self.EditingIndex:
self.BlockControls.append(self.Field)
continue
Text = self.Document[Item.Start:Item.End]
Reused = Cache.get(Text)
Control = Reused.pop() if Reused else self._Render(Text)
Control.bgcolor = None
self.BlockControls.append(Control)
self.RenderCache.setdefault(Text, []).append(Control)
Controls = list(self.BlockControls)
if self.EditingIndex == len(self.Blocks):
Controls.append(self.Field)
else:
Hint = "Click to start writing." if not self.Blocks else "Click here to add more."
Controls.append(ft.Container(
content=ft.Text(Hint, color=Palette.Overlay0, italic=True, size=self.FontSize - 2),
padding=ft.Padding.symmetric(horizontal=8, vertical=12),
on_click=lambda e: self._OnEndClicked(),
))
self.controls = Controls
if Update:
self.update()
+3 -1
View File
@@ -67,7 +67,9 @@ def Main(Page: ft.Page) -> None:
TheApp.Connect() # its listeners reload the tree and the browser
else:
Body.update()
Tree.Reload() # "Show files in the tree" may have changed
TheBrowser.Refresh()
Tree.RevealPath(TheBrowser.Root, TheBrowser.Path)
def ToggleSidebar() -> None:
Config.ShowSidebar = not Config.ShowSidebar
@@ -85,7 +87,7 @@ def Main(Page: ft.Page) -> None:
TheBrowser = Browser(TheApp, OnPathChanged, lambda: ShowSettings("Connection" if TheApp.ConnectError else "General"),
ToggleSidebar)
Tree = Filetree(TheApp, TheBrowser.NavigateTo)
Tree = Filetree(TheApp, TheBrowser.NavigateTo, TheBrowser.OpenFile)
Divider = ResizeHandle(OnDrag, OnDragEnd)
Tree.visible = Divider.visible = Config.ShowSidebar
# STRETCH so the divider gets the full height to be dragged along.
+14 -1
View File
@@ -99,6 +99,14 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
value=Config.OpenFilesInApp)
ConfirmDeleteSwitch = ft.Switch(label="Ask before deleting", value=Config.ConfirmDelete)
SidebarSwitch = ft.Switch(label="Show the directory tree (Ctrl+B)", value=Config.ShowSidebar)
TreeFilesSwitch = ft.Switch(label="Show files in the tree (off: directories only)", value=Config.ShowFilesInTree)
MarkdownEditingPicker = ft.RadioGroup(
ft.Column([
ft.Radio(value="Live", label="Live: click a part of the rendered file to edit its Markdown"),
ft.Radio(value="Separate", label="Separate: View and Edit are two modes"),
], spacing=0),
value=Config.MarkdownEditing,
)
FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160)
def ToggleMenuEntry(e) -> None:
@@ -190,7 +198,10 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
ft.Row([ChipButton("Export", OnExport), ChipButton("Import", OnImport)]),
], spacing=12, scroll=ft.ScrollMode.AUTO),
"General": ft.Column([
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, FontSizeField, MenuButton,
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, TreeFilesSwitch, FontSizeField, MenuButton,
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Markdown editing"),
MarkdownEditingPicker,
ft.Divider(height=1, color=Palette.Surface1),
OwnAppearance,
], spacing=12, scroll=ft.ScrollMode.AUTO),
@@ -227,6 +238,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
Config.OpenFilesInApp = OpenInAppSwitch.value
Config.ConfirmDelete = ConfirmDeleteSwitch.value
Config.ShowSidebar = SidebarSwitch.value
Config.ShowFilesInTree = TreeFilesSwitch.value
Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing
Config.FontSize = _ReadInt(FontSizeField, Config.FontSize, 8, 32)
Config.Mode = ModePicker.value or Config.Mode
Config.LocalRoot = (LocalRootField.value or "").strip() or "~"
+58 -6
View File
@@ -4,6 +4,9 @@ EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
- Markdown is rendered in View; the other text types are shown as
highlighted code. 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.
- With Markdown editing set to Live (Settings > General), Markdown opens in
LiveMarkdown instead: rendered, and click a block to edit it in place.
Edit/View becomes Source/Live there.
- Images are shown fitted to the window.
- Video and other files get their details and Open Externally (a server
file is downloaded to ~/.cache/SaraView first). Flet has no built-in
@@ -16,7 +19,8 @@ from typing import Callable, Optional
import flet as ft
from App import App
from Core import ChipButton, CodeStyleSheet, HumanSize, Palette
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
from LiveMarkdown import LiveMarkdown
from Sources import Entry, SourceError, TextFileTypes
LanguageByExtension = {
@@ -56,6 +60,9 @@ class FileView(ft.Container):
self.OnClose = OnClose
self.OnFocusChange = OnFocusChange
self.IsText = self.FileType in TextFileTypes
self.IsLive = (self.FileType == "markdown" and TheApp.Config.MarkdownEditing == "Live"
and TheApp.Source.CanWrite)
self.Live: Optional[LiveMarkdown] = None
self.IsEditing = False
self.IsDirty = False
self.Content = ""
@@ -63,6 +70,9 @@ class FileView(ft.Container):
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
self.ToggleButton = ChipButton("Edit", self._ToggleEdit, Icon=ft.Icons.EDIT_OUTLINED)
if self.IsLive:
self.ToggleButton.content = "Source"
self.ToggleButton.icon = ft.Icons.CODE
self.SaveButton = ChipButton("Save", lambda e: self.Save(), Icon=ft.Icons.SAVE_OUTLINED, Disabled=True)
CanWrite = TheApp.Source.CanWrite
if not CanWrite:
@@ -109,6 +119,10 @@ class FileView(ft.Container):
if self.IsText:
self.Content = Source.ReadText(self.Root, self.RelativePath)
self.TextField.value = self.Content
if self.IsLive:
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
self.OnFocusChange, self._OnTapLink)
return self.Live
return self._RenderedView()
if self.FileType == "image":
return ft.Image(src=Source.ReadBytes(self.Root, self.RelativePath), fit=ft.BoxFit.CONTAIN, expand=True)
@@ -124,7 +138,8 @@ class FileView(ft.Container):
value=Text,
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
on_tap_link=self._OnTapLink,
)
@@ -134,7 +149,8 @@ class FileView(ft.Container):
value=FencedBlock(Text, LanguageByExtension.get(Extension, "")),
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
)
else:
@@ -186,7 +202,7 @@ class FileView(ft.Container):
def CanSave(self) -> bool:
return self.IsText and self.IsDirty
def _OnTextChanged(self, e) -> None:
def _OnTextChanged(self, e=None) -> None:
if not self.IsDirty:
self.IsDirty = True
self.SaveButton.disabled = False
@@ -194,7 +210,17 @@ class FileView(ft.Container):
self.SaveButton.update()
self.StatusText.update()
@property
def Text(self) -> str:
"""What's being edited, in whichever view has it."""
if self.Live is not None and not self.IsEditing:
return self.Live.Text
return self.TextField.value or ""
def _ToggleEdit(self, e=None) -> None:
if self.Live is not None:
self._ToggleSource()
return
self.IsEditing = not self.IsEditing
if self.IsEditing:
self.Body.content = self.TextField
@@ -210,6 +236,32 @@ class FileView(ft.Container):
self.Body.update()
self.ToggleButton.update()
def _ToggleSource(self) -> None:
"""Live mode's toggle: the whole file as plain text, and back."""
self.IsEditing = not self.IsEditing
if self.IsEditing:
self.TextField.value = self.Live.Text
self.Live.EditingIndex = None # its open block's text is in the field now
self.Body.content = self.TextField
self.ToggleButton.content = "Live"
self.ToggleButton.icon = ft.Icons.VISIBILITY_OUTLINED
self.OnFocusChange(True)
else:
self.Live.SetText(self.TextField.value or "", Update=False) # not on the page yet
self.Body.content = self.Live
self.ToggleButton.content = "Source"
self.ToggleButton.icon = ft.Icons.CODE
self.OnFocusChange(False)
self.Body.update()
self.ToggleButton.update()
def HandleEscape(self) -> bool:
"""Esc first closes an open block in live mode; True if it did."""
if self.Live is not None and not self.IsEditing and self.Live.IsEditing:
self.Live.Commit()
return True
return False
def ToggleEdit(self) -> None:
if self.IsText and not self.ToggleButton.disabled:
self._ToggleEdit()
@@ -218,11 +270,11 @@ class FileView(ft.Container):
if not self.CanSave:
return
try:
self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.TextField.value or "")
self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.Text)
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't save {self.Item.Name}")
return
self.Content = self.TextField.value or ""
self.Content = self.Text
self.IsDirty = False
self.SaveButton.disabled = True
self.StatusText.value = "Saved."
+1
View File
@@ -13,6 +13,7 @@
"react-dom": "^18.3.1",
"@uiw/react-codemirror": "^4.23.0",
"@codemirror/view": "^6.34.1",
"@codemirror/language": "^6.10.3",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
+1
View File
@@ -387,6 +387,7 @@ export default function App() {
ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
ShowBlameAvatars={Settings ? Settings.ShowBlameAvatars : false}
StickyToolbar={Settings ? Settings.StickyToolbar : true}
MarkdownEditing={Settings ? Settings.MarkdownEditing : "Live"}
IsGuest={IsGuest}
/>
)}
+43 -1
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { gutter, GutterMarker } from "@codemirror/view";
import { markdown } from "@codemirror/lang-markdown";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { html } from "@codemirror/lang-html";
import { json } from "@codemirror/lang-json";
import MarkdownIt from "markdown-it";
@@ -9,6 +9,8 @@ import MarkdownItTaskLists from "markdown-it-task-lists";
import MarkdownItMark from "markdown-it-mark";
import MarkdownItFootnote from "markdown-it-footnote";
import { LivePreview } from "../LivePreview.js";
import {
FetchBlame,
FetchFile,
@@ -171,11 +173,13 @@ export default function PreviewPane({
ShowGitBlame,
ShowBlameAvatars,
StickyToolbar,
MarkdownEditing,
IsGuest,
}) {
const [Content, SetContent] = useState("");
const [ResolvedContent, SetResolvedContent] = useState("");
const [IsEditing, SetIsEditing] = useState(false);
const [IsSourceMode, SetIsSourceMode] = useState(false);
const [IsSaving, SetIsSaving] = useState(false);
const [SaveError, SetSaveError] = useState(null);
const [BlameMap, SetBlameMap] = useState({});
@@ -188,6 +192,7 @@ export default function PreviewPane({
useEffect(() => {
SetIsEditing(false);
SetIsSourceMode(false);
SetSaveError(null);
SetBlameMap({});
@@ -281,6 +286,43 @@ export default function PreviewPane({
);
}
// Live: one editor that shows the Markdown rendered and reveals the syntax
// where the cursor is (LivePreview.js). Source shows all of it, plain.
if (FileType === "markdown" && MarkdownEditing === "Live" && !IsGuest) {
return (
<div className="PreviewEditor LiveEditor">
<div className={ToolbarClass}>
<button onClick={() => SetIsSourceMode(!IsSourceMode)}>{IsSourceMode ? "Live" : "Source"}</button>
<button onClick={HandleSave} disabled={IsSaving}>
{IsSaving ? "Saving…" : "Save"}
</button>
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
</div>
<CodeMirror
value={Content}
height="100%"
basicSetup={{
lineNumbers: IsSourceMode,
foldGutter: IsSourceMode,
highlightActiveLine: IsSourceMode,
highlightActiveLineGutter: IsSourceMode,
syntaxHighlighting: IsSourceMode,
}}
extensions={[
markdown({ base: markdownLanguage }),
...(IsSourceMode ? [] : LivePreview),
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
]}
onChange={SetContent}
theme={IsSourceMode ? "dark" : "none"}
/>
</div>
);
}
if (FileType === "markdown" && !IsEditing) {
return (
<div className="PreviewMarkdown">
+11
View File
@@ -390,6 +390,17 @@ export default function SettingsPanel({
<Switch Checked={Settings.StickyToolbar} OnChange={(Event) => UpdateField("StickyToolbar", Event.target.checked)} />
</div>
<div className="SettingsRow">
<label className="SettingsLabel">Markdown editing</label>
<select
value={Settings.MarkdownEditing}
onChange={(Event) => UpdateField("MarkdownEditing", Event.target.value)}
>
<option value="Live">Live: edit the Markdown where you click in the rendered file</option>
<option value="Separate">Separate: View and Edit are two modes</option>
</select>
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Confirm before delete</label>
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
+235
View File
@@ -0,0 +1,235 @@
// Live Markdown editing, like Obsidian's live preview: the editor shows the
// file styled as it renders, with the Markdown syntax (#s, **, `, link URLs,
// > and list markers) hidden. Wherever the cursor is, the syntax comes back so
// it can be edited: a heading's #s on its line, a bold's ** inside it.
// The colours are the same roles as .MarkdownBody (Styles/Theme.css).
import { syntaxTree } from "@codemirror/language";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
class BulletWidget extends WidgetType {
eq() {
return true;
}
toDOM() {
const Element = document.createElement("span");
Element.className = "cm-LiveBullet";
Element.textContent = "•";
return Element;
}
}
class RuleWidget extends WidgetType {
eq() {
return true;
}
toDOM() {
const Element = document.createElement("span");
Element.className = "cm-LiveRule";
return Element;
}
}
// "[ ]" / "[x]" as a checkbox; clicking it ticks the task in the text.
class CheckboxWidget extends WidgetType {
constructor(Checked, Position) {
super();
this.Checked = Checked;
this.Position = Position;
}
eq(Other) {
return this.Checked === Other.Checked && this.Position === Other.Position;
}
toDOM(View) {
const Box = document.createElement("input");
Box.type = "checkbox";
Box.className = "cm-LiveCheckbox";
Box.checked = this.Checked;
Box.addEventListener("mousedown", (Event) => {
Event.preventDefault();
View.dispatch({
changes: { from: this.Position, to: this.Position + 3, insert: this.Checked ? "[ ]" : "[x]" },
});
});
return Box;
}
ignoreEvent() {
return true;
}
}
const Hidden = Decoration.replace({});
const Bullet = Decoration.replace({ widget: new BulletWidget() });
const Rule = Decoration.replace({ widget: new RuleWidget() });
const InlineClasses = {
Emphasis: "cm-LiveEm",
StrongEmphasis: "cm-LiveStrong",
Strikethrough: "cm-LiveStrike",
InlineCode: "cm-LiveCode",
Link: "cm-LiveLink",
TableHeader: "cm-LiveTableHead",
};
// Syntax that hides unless the cursor is inside its parent (keyed by parent).
const InlineMarks = {
EmphasisMark: ["Emphasis", "StrongEmphasis"],
StrikethroughMark: ["Strikethrough"],
CodeMark: ["InlineCode"],
LinkMark: ["Link"],
URL: ["Link"],
LinkTitle: ["Link"],
};
function BuildDecorations(View) {
const State = View.state;
const Doc = State.doc;
const Ranges = [];
const Focused = View.hasFocus;
// Nothing is "active" while the editor isn't focused: the whole file shows rendered.
function Touches(From, To) {
if (!Focused) return false;
return State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From);
}
function LineActive(Position) {
const Line = Doc.lineAt(Position);
return Touches(Line.from, Line.to);
}
function AddLineClass(From, To, ClassName) {
for (let Position = From; Position <= To; ) {
const Line = Doc.lineAt(Position);
Ranges.push(Decoration.line({ class: ClassName }).range(Line.from));
Position = Line.to + 1;
}
}
// A mark plus the space that separates it from the text ("# ", "> ", "- ").
function HideWithSpace(From, To) {
const End = To < Doc.length && Doc.sliceString(To, To + 1) === " " ? To + 1 : To;
Ranges.push(Hidden.range(From, End));
}
for (const { from, to } of View.visibleRanges) {
syntaxTree(State).iterate({
from,
to,
enter(Node) {
const Name = Node.name;
const Parent = Node.node.parent;
const ParentName = Parent ? Parent.name : "";
const HeadingMatch = /^(?:ATX|Setext)Heading(\d)$/.exec(Name);
if (HeadingMatch) {
const Line = Doc.lineAt(Node.from);
Ranges.push(Decoration.line({ class: `cm-LiveHeading cm-LiveH${HeadingMatch[1]}` }).range(Line.from));
return;
}
if (Name === "HeaderMark") {
if (ParentName.startsWith("ATXHeading") && !LineActive(Node.from)) {
const Line = Doc.lineAt(Node.from);
if (Node.from === Line.from || Doc.sliceString(Line.from, Node.from).trim() === "") {
HideWithSpace(Node.from, Node.to);
} else {
// Closing #s: hide them and the space before.
const Start = Doc.sliceString(Node.from - 1, Node.from) === " " ? Node.from - 1 : Node.from;
Ranges.push(Hidden.range(Start, Node.to));
}
} else if (ParentName.startsWith("SetextHeading")) {
Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to));
}
return;
}
if (InlineClasses[Name] && Node.to > Node.from) {
Ranges.push(Decoration.mark({ class: InlineClasses[Name] }).range(Node.from, Node.to));
}
if (InlineMarks[Name]) {
if (InlineMarks[Name].includes(ParentName)) {
if (!Touches(Parent.from, Parent.to) && Node.to > Node.from) {
Ranges.push(Hidden.range(Node.from, Node.to));
}
} else if (Name === "CodeMark" && ParentName === "FencedCode") {
Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to));
}
return;
}
if (Name === "Blockquote") {
AddLineClass(Node.from, Node.to, "cm-LiveQuote");
return;
}
if (Name === "QuoteMark") {
if (!LineActive(Node.from)) HideWithSpace(Node.from, Node.to);
return;
}
if (Name === "ListMark") {
const IsTask = Parent && Parent.getChild("Task");
if (LineActive(Node.from)) {
Ranges.push(Decoration.mark({ class: "cm-LiveListMark" }).range(Node.from, Node.to));
} else if (IsTask) {
HideWithSpace(Node.from, Node.to);
} else if (Parent && Parent.parent && Parent.parent.name === "BulletList") {
Ranges.push(Bullet.range(Node.from, Node.to));
} else {
Ranges.push(Decoration.mark({ class: "cm-LiveListMark" }).range(Node.from, Node.to));
}
return;
}
if (Name === "TaskMarker") {
if (!LineActive(Node.from)) {
const Checked = /x/i.test(Doc.sliceString(Node.from, Node.to));
Ranges.push(Decoration.replace({ widget: new CheckboxWidget(Checked, Node.from) }).range(Node.from, Node.to));
}
return;
}
if (Name === "HorizontalRule") {
if (!LineActive(Node.from)) Ranges.push(Rule.range(Node.from, Node.to));
return;
}
if (Name === "FencedCode") {
AddLineClass(Node.from, Node.to, "cm-LiveCodeBlock");
return;
}
if (Name === "CodeInfo" || Name === "TableDelimiter") {
Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to));
}
},
});
}
return Decoration.set(Ranges, true);
}
export const LivePreview = [
ViewPlugin.fromClass(
class {
constructor(View) {
this.decorations = BuildDecorations(View);
}
update(Update) {
if (Update.docChanged || Update.selectionSet || Update.viewportChanged || Update.focusChanged) {
this.decorations = BuildDecorations(Update.view);
}
}
},
{ decorations: (Plugin) => Plugin.decorations },
),
EditorView.lineWrapping,
];
+210 -3
View File
@@ -408,22 +408,96 @@ img,
line-height: 1.6;
}
.MarkdownBody h1,
.MarkdownBody h2,
.MarkdownBody h3 {
/* Rainbow headings and coloured inline styles. The live editor
(.LiveEditor below, LivePreview.js) and the desktop client
(Client/Core.py MarkdownSheet) use the same roles. */
.MarkdownBody h1 {
color: var(--Purple);
}
.MarkdownBody h2 {
color: var(--Blue);
}
.MarkdownBody h3 {
color: var(--Green);
}
.MarkdownBody h4 {
color: var(--Yellow);
}
.MarkdownBody h5 {
color: var(--Orange);
font-size: 1em;
}
.MarkdownBody h6 {
color: var(--Red);
font-size: 1em;
}
.MarkdownBody strong {
color: var(--Orange);
}
.MarkdownBody em {
color: var(--Yellow);
}
.MarkdownBody s,
.MarkdownBody del {
color: var(--TextMuted);
}
.MarkdownBody a {
color: var(--Blue);
}
.MarkdownBody code {
font-family: inherit;
font-size: inherit;
background: var(--SurfaceAlt);
color: var(--Green);
padding: 2px 4px;
border-radius: 3px;
}
.MarkdownBody pre {
font-family: inherit;
background: var(--Surface);
padding: 8px 12px;
border-radius: 4px;
overflow-x: auto;
}
.MarkdownBody pre code {
background: none;
color: var(--Text);
padding: 0;
}
.MarkdownBody blockquote {
margin: 12px 0;
padding: 6px 12px;
border-left: 3px solid var(--Purple);
background: var(--Surface);
color: var(--TextMuted);
}
.MarkdownBody li::marker {
color: var(--Purple);
}
.MarkdownBody hr {
border: none;
border-top: 1px solid var(--Border);
}
.MarkdownBody input[type="checkbox"] {
accent-color: var(--Purple);
}
.MarkdownBody img {
max-width: 100%;
}
@@ -461,6 +535,7 @@ img,
.MarkdownBody th {
background: var(--SurfaceAlt);
color: var(--Purple);
}
.MarkdownBody .footnotes {
@@ -476,6 +551,138 @@ img,
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 {
background: var(--Background);
color: var(--Text);
height: 100%;
}
.LiveEditor .cm-editor.cm-focused {
outline: none;
}
.LiveEditor .cm-content {
font-family: var(--FontFamily);
max-width: 720px;
padding: 24px;
line-height: 1.6;
caret-color: var(--Purple);
}
.LiveEditor .cm-cursor {
border-left-color: var(--Purple);
}
.LiveEditor .cm-selectionBackground,
.LiveEditor .cm-editor.cm-focused .cm-selectionBackground,
.LiveEditor .cm-content ::selection {
background: var(--SurfaceAlt);
}
.LiveEditor .cm-gutters {
background: var(--Surface);
color: var(--TextMuted);
border-right: 1px solid var(--Border);
}
.LiveEditor .cm-LiveHeading {
font-weight: bold;
}
.LiveEditor .cm-LiveH1 {
color: var(--Purple);
font-size: 2em;
}
.LiveEditor .cm-LiveH2 {
color: var(--Blue);
font-size: 1.5em;
}
.LiveEditor .cm-LiveH3 {
color: var(--Green);
font-size: 1.17em;
}
.LiveEditor .cm-LiveH4 {
color: var(--Yellow);
}
.LiveEditor .cm-LiveH5 {
color: var(--Orange);
}
.LiveEditor .cm-LiveH6 {
color: var(--Red);
}
.LiveEditor .cm-LiveStrong {
color: var(--Orange);
font-weight: bold;
}
.LiveEditor .cm-LiveEm {
color: var(--Yellow);
font-style: italic;
}
.LiveEditor .cm-LiveStrike {
color: var(--TextMuted);
text-decoration: line-through;
}
.LiveEditor .cm-LiveCode {
background: var(--SurfaceAlt);
color: var(--Green);
border-radius: 3px;
}
.LiveEditor .cm-LiveLink {
color: var(--Blue);
text-decoration: underline;
}
.LiveEditor .cm-LiveQuote {
border-left: 3px solid var(--Purple);
background: var(--Surface);
color: var(--TextMuted);
padding-left: 12px;
}
.LiveEditor .cm-LiveBullet,
.LiveEditor .cm-LiveListMark {
color: var(--Purple);
}
.LiveEditor .cm-LiveCheckbox {
accent-color: var(--Purple);
margin: 0 6px 0 0;
vertical-align: middle;
cursor: pointer;
}
.LiveEditor .cm-LiveRule {
display: inline-block;
width: 100%;
vertical-align: middle;
border-top: 1px solid var(--Border);
}
.LiveEditor .cm-LiveCodeBlock {
background: var(--Surface);
}
.LiveEditor .cm-LiveTableHead {
color: var(--Purple);
font-weight: bold;
}
.LiveEditor .cm-LiveMuted {
color: var(--TextMuted);
}
.PreviewText {
flex: 1;
min-width: 0;
+8 -4
View File
@@ -274,8 +274,8 @@ in the blame gutter, using each author's chosen colour either way.
## Desktop Client
`Client/` is SaraView as a desktop app, built with Flet and laid out like
SaraFem: a directory tree on the left, the file list on the right, and an
open file in place of the list. It has two modes, picked in Settings >
SaraFem: a tree of directories and files on the left, the file list on the
right, and an open file in place of the list. It has two modes, picked in Settings >
Connection:
- **Local**: browses one root directory on your own computer (any path).
@@ -288,8 +288,12 @@ Connection:
locks are enforced by the server as usual, and SaraTag tags show above
the file. Download saves a file, or a directory as a .zip.
Markdown is rendered in View, config and HTML files are highlighted, and
Edit/Save (Ctrl+E, Ctrl+S) work like the web view. Images show in the app;
Config and HTML files are highlighted, and Edit/Save (Ctrl+E, Ctrl+S) work
like the web view. Markdown follows the "Markdown editing" setting (also in
the web view's settings): **Live** shows it rendered and lets you edit
in place, like Obsidian (click a heading to see and edit its `#`s; Esc puts
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).
Rename takes a `/` to move, like the web view (`../Archive/Notes.md`).