mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Add live Markdown editing, colours and files in the tree
This commit is contained in:
1 parent
0eecfba224
commit
f851ecd820
15 files changed
+1025
-58
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user