mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Live editor: links and images open on click
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0513d94c6a
commit
f3287b207e
3 files changed
+127
-4
No files matched your search
@@ -9,7 +9,7 @@ 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 { LinkActions, LivePreview } from "../LivePreview.js";
|
||||
import { DiagramHtml, RenderDiagramsIn } from "../Mermaid.js";
|
||||
import { ParseProperties, PropertiesHtml, TaskLines, ToggleLine, WithoutProperties } from "../Properties.js";
|
||||
|
||||
@@ -208,7 +208,31 @@ export default function PreviewPane({
|
||||
const [SaveError, SetSaveError] = useState(null);
|
||||
const [BlameMap, SetBlameMap] = useState({});
|
||||
const MarkdownBodyRef = useRef(null);
|
||||
const LiveExtensions = useMemo(() => LivePreview(RootName, FilePath), [RootName, FilePath]);
|
||||
// What the live editor's links and images do: notes open here (the latest
|
||||
// OnNavigateLink, through a ref), anything else in a new tab.
|
||||
const NavigateRef = useRef(OnNavigateLink);
|
||||
NavigateRef.current = OnNavigateLink;
|
||||
const LiveExtensions = useMemo(
|
||||
() => [
|
||||
...LivePreview(RootName, FilePath),
|
||||
LinkActions.of({
|
||||
Open: (Href) => {
|
||||
if (!Href || Href.startsWith("#")) return;
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Href);
|
||||
if (ResolvedPath === null) {
|
||||
window.open(Href, "_blank", "noopener");
|
||||
} else {
|
||||
NavigateRef.current(ResolvedPath, ClassifyLinkedFile(ResolvedPath));
|
||||
}
|
||||
},
|
||||
ImageUrl: (Src) => {
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Src);
|
||||
return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath);
|
||||
},
|
||||
}),
|
||||
],
|
||||
[RootName, FilePath],
|
||||
);
|
||||
|
||||
function RefreshResolvedContent() {
|
||||
FetchResolvedFile(RootName, FilePath)
|
||||
|
||||
@@ -10,13 +10,59 @@
|
||||
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { Facet, StateEffect, StateField } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||
|
||||
import { FetchSpans } from "./Api.js";
|
||||
import { RenderDiagram } from "./Mermaid.js";
|
||||
import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js";
|
||||
|
||||
// How the live editor opens what's clicked: PreviewPane gives it, so notes
|
||||
// open in SaraView and web links in a new tab. ImageUrl turns an image's src
|
||||
// (relative to the note) into a URL the browser can load.
|
||||
export const LinkActions = Facet.define({
|
||||
combine: (Values) => Values[0] || { Open: () => {}, ImageUrl: (Src) => Src },
|
||||
});
|
||||
|
||||
// A click on a link inside a widget (properties, a variable's result) opens it.
|
||||
function OpenFromWidget(View, Event) {
|
||||
const Anchor = Event.target.closest("a");
|
||||
if (!Anchor) return false;
|
||||
Event.preventDefault();
|
||||
View.state.facet(LinkActions).Open(Anchor.getAttribute("href"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// An image, shown; clicking it opens it.
|
||||
class ImageWidget extends WidgetType {
|
||||
constructor(Src, Alt) {
|
||||
super();
|
||||
this.Src = Src;
|
||||
this.Alt = Alt;
|
||||
}
|
||||
|
||||
eq(Other) {
|
||||
return this.Src === Other.Src && this.Alt === Other.Alt;
|
||||
}
|
||||
|
||||
toDOM(View) {
|
||||
const Image = document.createElement("img");
|
||||
Image.className = "cm-LiveImage";
|
||||
Image.src = View.state.facet(LinkActions).ImageUrl(this.Src);
|
||||
Image.alt = this.Alt;
|
||||
Image.title = this.Alt || this.Src;
|
||||
Image.addEventListener("mousedown", (Event) => {
|
||||
Event.preventDefault();
|
||||
View.state.facet(LinkActions).Open(this.Src);
|
||||
});
|
||||
return Image;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class BulletWidget extends WidgetType {
|
||||
eq() {
|
||||
return true;
|
||||
@@ -131,6 +177,7 @@ class PropertiesWidget extends WidgetType {
|
||||
Element.style.padding = "0";
|
||||
Element.innerHTML = this.Html;
|
||||
Element.addEventListener("mousedown", (Event) => {
|
||||
if (OpenFromWidget(View, Event)) return;
|
||||
Event.preventDefault();
|
||||
const Box = Event.target.closest(".PropertyCheckbox");
|
||||
const Doc = View.state.doc;
|
||||
@@ -250,6 +297,20 @@ function BuildDecorations(View) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Name === "Image") {
|
||||
if (!Touches(Node.from, Node.to)) {
|
||||
const Url = Node.node.getChild("URL");
|
||||
const Text = Doc.sliceString(Node.from, Node.to);
|
||||
const Alt = (/^!\[([^\]]*)\]/.exec(Text) || [, ""])[1];
|
||||
if (Url) {
|
||||
const Widget = new ImageWidget(Doc.sliceString(Url.from, Url.to), Alt);
|
||||
Ranges.push(Decoration.replace({ widget: Widget }).range(Node.from, Node.to));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (InlineClasses[Name] && Node.to > Node.from) {
|
||||
Ranges.push(Decoration.mark({ class: InlineClasses[Name] }).range(Node.from, Node.to));
|
||||
}
|
||||
@@ -445,7 +506,7 @@ class VariableWidget extends WidgetType {
|
||||
Element.className = Lines ? "cm-LiveVariableBlock" : "cm-LiveVariable";
|
||||
Element.innerHTML = Lines ? VariableRenderer.render(this.Text) : VariableRenderer.renderInline(this.Text);
|
||||
Element.addEventListener("mousedown", (Event) => {
|
||||
if (Event.target.closest("a")) return;
|
||||
if (OpenFromWidget(View, Event)) return;
|
||||
Event.preventDefault();
|
||||
View.dispatch({ selection: { anchor: View.posAtDOM(Element) } });
|
||||
View.focus();
|
||||
@@ -541,7 +602,38 @@ export function LivePreview(RootName, FilePath) {
|
||||
return [...LiveCore, Spans, Variables, FetchVariables(RootName, FilePath)];
|
||||
}
|
||||
|
||||
function LinkAt(State, Position) {
|
||||
for (let Node = syntaxTree(State).resolveInner(Position, 1); Node; Node = Node.parent) {
|
||||
if (Node.name === "Link") {
|
||||
const Url = Node.getChild("URL");
|
||||
return Url ? { From: Node.from, To: Node.to, Href: State.doc.sliceString(Url.from, Url.to) } : null;
|
||||
}
|
||||
if (Node.name === "URL" || Node.name === "Autolink") {
|
||||
const Href = State.doc.sliceString(Node.from, Node.to).replace(/^<|>$/g, "");
|
||||
return { From: Node.from, To: Node.to, Href };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ClickLinks = EditorView.domEventHandlers({
|
||||
mousedown(Event, View) {
|
||||
if (Event.button !== 0) return false;
|
||||
const Position = View.posAtCoords({ x: Event.clientX, y: Event.clientY });
|
||||
if (Position === null) return false;
|
||||
const Link = LinkAt(View.state, Position);
|
||||
if (!Link) return false;
|
||||
const Editing =
|
||||
View.hasFocus && View.state.selection.ranges.some((Range) => Range.from <= Link.To && Range.to >= Link.From);
|
||||
if (Editing && !(Event.ctrlKey || Event.metaKey)) return false;
|
||||
Event.preventDefault();
|
||||
View.state.facet(LinkActions).Open(Link.Href);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const LiveCore = [
|
||||
ClickLinks,
|
||||
Focused,
|
||||
TrackFocus,
|
||||
PropertiesPanel,
|
||||
|
||||
@@ -618,6 +618,12 @@ img,
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.LiveEditor .cm-LiveImage {
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.LiveEditor .cm-LiveFrontmatter {
|
||||
color: var(--TextMuted);
|
||||
}
|
||||
@@ -854,6 +860,7 @@ img,
|
||||
.LiveEditor .cm-LiveLink {
|
||||
color: var(--Blue);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.LiveEditor .cm-LiveQuote {
|
||||
|
||||
Reference in new issue
Block a user