Live editor: links and images open on click

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 18:22:51 +02:00
1 parent 0513d94c6a
commit f3287b207e
3 files changed
+127 -4

No files matched your search

+94 -2
View File
@@ -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,