From f3287b207e5c0408ce183cde257f4a4101317985 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 18:22:51 +0200 Subject: [PATCH] Live editor: links and images open on click Co-Authored-By: Claude Opus 5.5 --- Frontend/src/Components/PreviewPane.jsx | 28 +++++++- Frontend/src/LivePreview.js | 96 ++++++++++++++++++++++++- Frontend/src/Styles/Theme.css | 7 ++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 4972e60..828a2e8 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -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) diff --git a/Frontend/src/LivePreview.js b/Frontend/src/LivePreview.js index b1d0336..d6f8161 100644 --- a/Frontend/src/LivePreview.js +++ b/Frontend/src/LivePreview.js @@ -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, diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index aef2c75..f10e328 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -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 {