diff --git a/Backend/Settings.py b/Backend/Settings.py index a68d9e3..3fb5d3c 100644 --- a/Backend/Settings.py +++ b/Backend/Settings.py @@ -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" diff --git a/Client/Browser.py b/Client/Browser.py index 2deadf5..661142d 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -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 diff --git a/Client/Core.py b/Client/Core.py index 047b9f2..34669af 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -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) diff --git a/Client/Filetree.py b/Client/Filetree.py index f9e23bc..e6eaa22 100644 --- a/Client/Filetree.py +++ b/Client/Filetree.py @@ -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 diff --git a/Client/LiveMarkdown.py b/Client/LiveMarkdown.py new file mode 100644 index 0000000..e912f61 --- /dev/null +++ b/Client/LiveMarkdown.py @@ -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() diff --git a/Client/Main.py b/Client/Main.py index 52733d8..8c2e895 100644 --- a/Client/Main.py +++ b/Client/Main.py @@ -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. diff --git a/Client/Settings.py b/Client/Settings.py index 65047ef..4434966 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -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 "~" diff --git a/Client/Viewer.py b/Client/Viewer.py index 1587d92..08c7b05 100644 --- a/Client/Viewer.py +++ b/Client/Viewer.py @@ -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." diff --git a/Frontend/package.json b/Frontend/package.json index 88b8ceb..ebd47f4 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -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", diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 03a1ed3..a4eabb1 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -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} /> )} diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 43ef66e..725f986 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -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 ( +
+
+ + + + Download + + {SaveError && {SaveError}} +
+ +
+ ); + } + if (FileType === "markdown" && !IsEditing) { return (
diff --git a/Frontend/src/Components/SettingsPanel.jsx b/Frontend/src/Components/SettingsPanel.jsx index bcc801e..53b5966 100644 --- a/Frontend/src/Components/SettingsPanel.jsx +++ b/Frontend/src/Components/SettingsPanel.jsx @@ -390,6 +390,17 @@ export default function SettingsPanel({ UpdateField("StickyToolbar", Event.target.checked)} />
+
+ + +
+
UpdateField("ConfirmBeforeDelete", Event.target.checked)} /> diff --git a/Frontend/src/LivePreview.js b/Frontend/src/LivePreview.js new file mode 100644 index 0000000..2ffbf7b --- /dev/null +++ b/Frontend/src/LivePreview.js @@ -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, +]; diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index 8161f9a..138975f 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -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; diff --git a/README.md b/README.md index 8403e29..0f1b450 100644 --- a/README.md +++ b/README.md @@ -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`).