mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user