mirror of
https://github.com/yaye-work/inline-note.git
synced 2026-07-22 08:28:50 +00:00
When an inline card sits on the note's last line, the space beneath it belonged to no line at all — clicks there did nothing, leaving no way to keep writing in the parent. Clicking that dead zone now appends a fresh line after the link line and places the cursor there. Gated to exactly that case (imprecise hit, below end-of-doc, open inline on the last line), so click-below-content behavior in ordinary notes is untouched.
1108 lines
33 KiB
JavaScript
1108 lines
33 KiB
JavaScript
"use strict";
|
|
|
|
const {
|
|
Plugin,
|
|
PluginSettingTab,
|
|
Setting,
|
|
Notice,
|
|
setIcon,
|
|
editorInfoField,
|
|
debounce,
|
|
Component,
|
|
MarkdownRenderer,
|
|
} = require("obsidian");
|
|
const { Decoration, WidgetType, EditorView, keymap } = require("@codemirror/view");
|
|
const { StateEffect, StateField, Prec } = require("@codemirror/state");
|
|
const { history, defaultKeymap, historyKeymap } = require("@codemirror/commands");
|
|
|
|
let AUTOCOMPLETE = null;
|
|
try {
|
|
AUTOCOMPLETE = require("@codemirror/autocomplete");
|
|
} catch (e) {
|
|
/* fallback editor only: no [[ suggestions, all else works */
|
|
}
|
|
|
|
const MAX_DEPTH = 5;
|
|
const PREVIEW_LIMIT = 200;
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
nestStyle: "card", // "line" | "card" | "card-line"
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* State. Each link is "closed" (default) or "preview" (open). Long */
|
|
/* notes get a fixed-height scrollable body. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const setInlineState = StateEffect.define();
|
|
|
|
const inlineStateField = StateField.define({
|
|
create() {
|
|
return new Map();
|
|
},
|
|
update(value, tr) {
|
|
let next = value;
|
|
for (const e of tr.effects) {
|
|
if (e.is(setInlineState)) {
|
|
if (next === value) next = new Map(value);
|
|
next.set(e.value.link, e.value.state);
|
|
}
|
|
}
|
|
return next;
|
|
},
|
|
});
|
|
|
|
function getInlineState(state, linkText) {
|
|
return state.field(inlineStateField).get(linkText) || "closed";
|
|
}
|
|
|
|
const LINK_RE = /\[\[([^\[\]|#\n]+)(?:[#|][^\]\n]*)?\]\]/g;
|
|
const EMBED_RE = /!\[\[([^\[\]\n]+)\]\]/g;
|
|
// links like [[photo.png]] name a non-markdown file — never inline those
|
|
const NON_MD_EXT_RE = /\.(?!md$)[a-zA-Z0-9]{1,8}$/;
|
|
|
|
/* nested fallback editors reveal [[brackets]] only while focused with
|
|
* the cursor inside the link — so we track focus as editor state */
|
|
const setFocused = StateEffect.define();
|
|
|
|
const focusedField = StateField.define({
|
|
create() {
|
|
return false;
|
|
},
|
|
update(value, tr) {
|
|
for (const e of tr.effects) {
|
|
if (e.is(setFocused)) value = e.value;
|
|
}
|
|
return value;
|
|
},
|
|
});
|
|
|
|
function linkAt(state, pos, requireAtEnd) {
|
|
const line = state.doc.lineAt(pos);
|
|
LINK_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = LINK_RE.exec(line.text)) !== null) {
|
|
// ![[...]] is an embed, not a toggleable link
|
|
if (m.index > 0 && line.text[m.index - 1] === "!") continue;
|
|
const start = line.from + m.index;
|
|
const end = start + m[0].length;
|
|
const hit = requireAtEnd ? pos === end : pos >= start && pos <= end;
|
|
if (hit && m[1].trim()) return m[1].trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* completion source for [[ inside FALLBACK editors: suggests vault
|
|
* files. The native embedded editor uses Obsidian's own suggester. */
|
|
function linkCompletionSource(plugin) {
|
|
return (ctx) => {
|
|
const m = ctx.matchBefore(/!?\[\[[^\[\]]*$/);
|
|
if (!m) return null;
|
|
const from = m.from + m.text.indexOf("[[") + 2;
|
|
const options = plugin.app.vault.getFiles().map((f) => {
|
|
const label = f.extension === "md" ? f.basename : f.name;
|
|
return { label, type: "file", apply: label + "]]" };
|
|
});
|
|
return { from, options, validFor: /^[^\[\]]*$/ };
|
|
};
|
|
}
|
|
|
|
/* focus moving into Obsidian popovers (link suggester, menus, CM
|
|
* tooltips) must not count as "left the inline body" */
|
|
function focusMovedToPopover(e) {
|
|
return (
|
|
e.relatedTarget instanceof Element &&
|
|
!!e.relatedTarget.closest(".suggestion-container, .cm-tooltip, .menu, .modal-container")
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Link toggle widget: chevron once the note exists (always visible), */
|
|
/* file-plus while it doesn't (shows on line hover). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class LinkToggleWidget extends WidgetType {
|
|
constructor(plugin, linkText, sourcePath, depth, exists, open, canInline) {
|
|
super();
|
|
this.plugin = plugin;
|
|
this.linkText = linkText;
|
|
this.sourcePath = sourcePath;
|
|
this.depth = depth;
|
|
this.exists = exists;
|
|
this.open = open;
|
|
this.canInline = canInline;
|
|
}
|
|
|
|
eq(other) {
|
|
return (
|
|
other.linkText === this.linkText &&
|
|
other.exists === this.exists &&
|
|
other.open === this.open &&
|
|
other.canInline === this.canInline
|
|
);
|
|
}
|
|
|
|
toDOM(view) {
|
|
const btn = document.createElement("span");
|
|
btn.className = "inline-note-btn";
|
|
if (this.exists) btn.classList.add("is-visible");
|
|
if (!this.exists) {
|
|
btn.setAttribute("aria-label", "Create inline (Tab after ]])");
|
|
setIcon(btn, "file-plus-2");
|
|
} else if (!this.canInline) {
|
|
btn.setAttribute("aria-label", "Open note");
|
|
setIcon(btn, "lucide-external-link");
|
|
} else {
|
|
btn.setAttribute("aria-label", this.open ? "Collapse (Tab after ]])" : "Expand (Tab after ]])");
|
|
setIcon(btn, this.open ? "chevron-down" : "chevron-right");
|
|
}
|
|
btn.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (this.exists && !this.canInline) {
|
|
this.plugin.app.workspace.openLinkText(this.linkText, this.sourcePath, false);
|
|
return;
|
|
}
|
|
this.plugin.toggleInlineNote(view, this.linkText, this.sourcePath, this.depth);
|
|
});
|
|
return btn;
|
|
}
|
|
|
|
ignoreEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Embed widget: renders ![[...]] inside FALLBACK editors through */
|
|
/* Obsidian's MarkdownRenderer (PDFs, images, note embeds). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class EmbedWidget extends WidgetType {
|
|
constructor(plugin, target, sourcePath) {
|
|
super();
|
|
this.plugin = plugin;
|
|
this.target = target;
|
|
this.sourcePath = sourcePath;
|
|
}
|
|
|
|
eq(other) {
|
|
return other.target === this.target && other.sourcePath === this.sourcePath;
|
|
}
|
|
|
|
toDOM() {
|
|
const el = document.createElement("span");
|
|
el.className = "inline-note-embedded markdown-rendered";
|
|
this._component = new Component();
|
|
this._component.load();
|
|
MarkdownRenderer.render(
|
|
this.plugin.app,
|
|
"![[" + this.target + "]]",
|
|
el,
|
|
this.sourcePath,
|
|
this._component
|
|
).catch(() => {
|
|
el.textContent = "![[" + this.target + "]]";
|
|
});
|
|
el.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
return el;
|
|
}
|
|
|
|
destroy() {
|
|
if (this._component) this._component.unload();
|
|
}
|
|
|
|
ignoreEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Inline body: Obsidian's own embedded markdown editor (the Canvas */
|
|
/* mechanism) — live preview, native commands, native suggester, */
|
|
/* native drag & drop. The same editor serves focused and idle states, */
|
|
/* so the card always renders exactly like the note in its own pane. */
|
|
/* Falls back to a bare CodeMirror editor when the internal API is */
|
|
/* unavailable. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class InlineNoteWidget extends WidgetType {
|
|
constructor(plugin, linkText, sourcePath, depth, ancestors) {
|
|
super();
|
|
this.plugin = plugin;
|
|
this.linkText = linkText;
|
|
this.sourcePath = sourcePath;
|
|
this.depth = depth;
|
|
this.ancestors = ancestors;
|
|
}
|
|
|
|
eq(other) {
|
|
return (
|
|
other.linkText === this.linkText &&
|
|
other.sourcePath === this.sourcePath
|
|
);
|
|
}
|
|
|
|
toDOM(view) {
|
|
const panel = buildInlineBody(
|
|
this.plugin,
|
|
this.linkText,
|
|
this.sourcePath,
|
|
this.depth,
|
|
this.ancestors,
|
|
view
|
|
);
|
|
this._destroyPanel = panel.destroy;
|
|
panel.el.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
return panel.el;
|
|
}
|
|
|
|
destroy() {
|
|
if (this._destroyPanel) this._destroyPanel();
|
|
}
|
|
|
|
ignoreEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** Paths of the inline bodies this element sits inside (DOM walk —
|
|
* works across native embedded editors where CM state can't carry
|
|
* ancestry). */
|
|
function domAncestorPaths(el) {
|
|
const out = [];
|
|
let cur = el;
|
|
while ((cur = cur.parentElement)) {
|
|
if (cur.classList.contains("inline-note-flow") && cur.dataset.notePath) {
|
|
out.push(cur.dataset.notePath);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildInlineBody(plugin, linkText, sourcePath, depth, ancestors, parentView) {
|
|
const app = plugin.app;
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "inline-note-flow";
|
|
|
|
const contentEl = wrap.appendChild(document.createElement("div"));
|
|
contentEl.className = "inline-note-content";
|
|
|
|
let fallbackEditor = null; // CM EditorView (fallback path)
|
|
let nativeEmbed = null; // Obsidian MarkdownEmbed (preferred path)
|
|
let dirty = false;
|
|
let currentContent = "";
|
|
let childSourcePath = sourcePath;
|
|
let childAncestors = ancestors;
|
|
|
|
const bodyText = () => {
|
|
if (nativeEmbed) {
|
|
try {
|
|
if (nativeEmbed.editMode) {
|
|
if (typeof nativeEmbed.editMode.get === "function") {
|
|
return nativeEmbed.editMode.get();
|
|
}
|
|
const cm =
|
|
nativeEmbed.editMode.editor && nativeEmbed.editMode.editor.cm;
|
|
if (cm) return cm.state.doc.toString();
|
|
}
|
|
} catch (e) {
|
|
/* fall through */
|
|
}
|
|
return null;
|
|
}
|
|
if (fallbackEditor) return fallbackEditor.state.doc.toString();
|
|
return null;
|
|
};
|
|
|
|
const saveSelf = async () => {
|
|
const file = plugin.resolveLink(linkText, sourcePath);
|
|
if (!file) return;
|
|
if (nativeEmbed) {
|
|
const text = bodyText();
|
|
if (text != null && text !== currentContent) {
|
|
currentContent = text;
|
|
await app.vault.modify(file, text);
|
|
}
|
|
return;
|
|
}
|
|
if (!fallbackEditor || !dirty) return;
|
|
dirty = false;
|
|
currentContent = fallbackEditor.state.doc.toString();
|
|
await app.vault.modify(file, currentContent);
|
|
};
|
|
const debouncedSave = debounce(saveSelf, 500, true);
|
|
|
|
const clearBody = () => {
|
|
if (nativeEmbed) {
|
|
try {
|
|
nativeEmbed.unload();
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
nativeEmbed = null;
|
|
}
|
|
if (fallbackEditor) {
|
|
fallbackEditor.destroy();
|
|
fallbackEditor = null;
|
|
}
|
|
while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
|
|
};
|
|
|
|
const mdLinkFor = (file) => {
|
|
let link = app.fileManager.generateMarkdownLink(file, childSourcePath);
|
|
if (file.extension !== "md" && !link.startsWith("!")) link = "!" + link;
|
|
return link;
|
|
};
|
|
|
|
const dropIsHandleable = (e) => {
|
|
const dr = app.dragManager && app.dragManager.draggable;
|
|
if (dr && (dr.file || (dr.files && dr.files.length))) return true;
|
|
return !!(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length);
|
|
};
|
|
const collectDropLinks = async (e) => {
|
|
const dr = app.dragManager && app.dragManager.draggable;
|
|
const links = [];
|
|
if (dr && (dr.file || (dr.files && dr.files.length))) {
|
|
const files = dr.files || [dr.file];
|
|
for (const f of files) {
|
|
if (f && f.path) links.push(mdLinkFor(f));
|
|
}
|
|
} else if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
|
for (const f of Array.from(e.dataTransfer.files)) {
|
|
const path = app.fileManager.getAvailablePathForAttachment
|
|
? await app.fileManager.getAvailablePathForAttachment(f.name, childSourcePath)
|
|
: f.name;
|
|
const buf = await f.arrayBuffer();
|
|
const created = await app.vault.createBinary(path, buf);
|
|
links.push(mdLinkFor(created));
|
|
}
|
|
}
|
|
return links.length ? links.join("\n") : null;
|
|
};
|
|
|
|
/** shared exit path on blur: reclaim an untouched empty note,
|
|
* otherwise just save. The editor stays mounted — the body is the
|
|
* same live-preview editor whether focused or idle, so the card
|
|
* always renders exactly like the note does in its own pane. */
|
|
const leaveBody = (text) => {
|
|
currentContent = text != null ? text : currentContent;
|
|
if (currentContent.trim() === "") {
|
|
plugin
|
|
.maybeReclaimEmpty(linkText, sourcePath, currentContent)
|
|
.then((reclaimed) => {
|
|
if (reclaimed) {
|
|
dirty = false;
|
|
parentView.dispatch({
|
|
effects: setInlineState.of({ link: linkText, state: "closed" }),
|
|
});
|
|
} else {
|
|
saveSelf();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
saveSelf();
|
|
};
|
|
|
|
/* the body view: Obsidian's own embedded editor (the Canvas
|
|
* mechanism). Returns a promise resolving to success. */
|
|
const mountNative = async (file, focus) => {
|
|
const factory =
|
|
app.embedRegistry &&
|
|
app.embedRegistry.embedByExtension &&
|
|
app.embedRegistry.embedByExtension.md;
|
|
if (!factory) return false;
|
|
const container = document.createElement("div");
|
|
container.className = "inline-note-native markdown-rendered";
|
|
try {
|
|
const embed = factory({ app, containerEl: container, state: {} }, file, "");
|
|
if (!embed || typeof embed.showEditor !== "function") return false;
|
|
embed.editable = true;
|
|
contentEl.appendChild(container);
|
|
embed.load();
|
|
if (typeof embed.loadFile === "function") await embed.loadFile();
|
|
embed.showEditor();
|
|
if (!embed.editMode) {
|
|
embed.unload();
|
|
container.remove();
|
|
return false;
|
|
}
|
|
nativeEmbed = embed;
|
|
|
|
// editor commands (bold, checkbox, …) target the active editor
|
|
container.addEventListener("focusin", () => {
|
|
try {
|
|
app.workspace.activeEditor = embed;
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
});
|
|
container.addEventListener("focusout", (e) => {
|
|
if (e.relatedTarget instanceof Node && wrap.contains(e.relatedTarget)) return;
|
|
if (focusMovedToPopover(e)) return;
|
|
if (!nativeEmbed) return;
|
|
leaveBody(bodyText());
|
|
});
|
|
|
|
// focus the editor only when the open came from an explicit
|
|
// expand, so typing starts immediately; idle (re)mounts must
|
|
// not steal focus
|
|
if (focus) {
|
|
const cm = embed.editMode.editor && embed.editMode.editor.cm;
|
|
if (cm && typeof cm.focus === "function") cm.focus();
|
|
else {
|
|
const content = container.querySelector(".cm-content");
|
|
if (content) content.focus();
|
|
}
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
console.error("inline-note: native editor failed, falling back", e);
|
|
try {
|
|
if (nativeEmbed) nativeEmbed.unload();
|
|
} catch (e2) {
|
|
/* ignore */
|
|
}
|
|
nativeEmbed = null;
|
|
container.remove();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/* fallback editing view: bare CodeMirror */
|
|
const mountFallback = (focus) => {
|
|
fallbackEditor = new EditorView({
|
|
doc: currentContent,
|
|
parent: contentEl,
|
|
extensions: [
|
|
history(),
|
|
keymap.of([...historyKeymap, ...defaultKeymap]),
|
|
EditorView.lineWrapping,
|
|
focusedField,
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.docChanged) {
|
|
dirty = true;
|
|
debouncedSave();
|
|
}
|
|
if (u.focusChanged) {
|
|
const focused = u.view.hasFocus;
|
|
queueMicrotask(() =>
|
|
u.view.dispatch({ effects: setFocused.of(focused) })
|
|
);
|
|
}
|
|
}),
|
|
...(AUTOCOMPLETE
|
|
? [
|
|
AUTOCOMPLETE.autocompletion({
|
|
override: [linkCompletionSource(plugin)],
|
|
icons: false,
|
|
}),
|
|
keymap.of(AUTOCOMPLETE.completionKeymap),
|
|
]
|
|
: []),
|
|
EditorView.domEventHandlers({
|
|
mousedown: (e, view) => {
|
|
const el =
|
|
e.target instanceof Element &&
|
|
e.target.closest(".inline-note-link-clickable");
|
|
if (!el) return false;
|
|
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
|
|
if (pos == null) return false;
|
|
const target = linkAt(view.state, pos, false);
|
|
if (!target) return false;
|
|
e.preventDefault();
|
|
app.workspace.openLinkText(
|
|
target,
|
|
childSourcePath,
|
|
e.metaKey || e.ctrlKey
|
|
);
|
|
return true;
|
|
},
|
|
dragover: (e) => {
|
|
if (!dropIsHandleable(e)) return false;
|
|
e.preventDefault();
|
|
return true;
|
|
},
|
|
drop: (e, view) => {
|
|
if (!dropIsHandleable(e)) return false;
|
|
e.preventDefault();
|
|
const pos =
|
|
view.posAtCoords({ x: e.clientX, y: e.clientY }) ??
|
|
view.state.selection.main.head;
|
|
collectDropLinks(e)
|
|
.then((text) => {
|
|
if (!text || !fallbackEditor) return;
|
|
fallbackEditor.dispatch({
|
|
changes: { from: pos, insert: text },
|
|
selection: { anchor: pos + text.length },
|
|
});
|
|
fallbackEditor.focus();
|
|
})
|
|
.catch((err) =>
|
|
new Notice("Inline Note: drop failed — " + err.message)
|
|
);
|
|
return true;
|
|
},
|
|
}),
|
|
buildInlineExtensions(
|
|
plugin,
|
|
() => childSourcePath,
|
|
depth,
|
|
() => childAncestors
|
|
),
|
|
EditorView.contentAttributes.of({ "aria-label": linkText }),
|
|
],
|
|
});
|
|
fallbackEditor.dom.classList.add("inline-note-editor");
|
|
fallbackEditor.dom.addEventListener("focusout", (e) => {
|
|
if (e.relatedTarget instanceof Node && wrap.contains(e.relatedTarget)) return;
|
|
if (focusMovedToPopover(e)) return;
|
|
if (!fallbackEditor) return;
|
|
leaveBody(fallbackEditor.state.doc.toString());
|
|
});
|
|
if (focus) fallbackEditor.focus();
|
|
};
|
|
|
|
const mountEditor = (focus) => {
|
|
clearBody();
|
|
const file = plugin.resolveLink(linkText, sourcePath);
|
|
if (file) {
|
|
mountNative(file, focus).then((ok) => {
|
|
if (!ok && wrap.isConnected && !nativeEmbed && !fallbackEditor) {
|
|
clearBody();
|
|
mountFallback(focus);
|
|
}
|
|
});
|
|
} else {
|
|
mountFallback(focus);
|
|
}
|
|
};
|
|
|
|
// The file may have been created a moment ago and the metadata cache
|
|
// might not have caught up yet — retry resolution briefly.
|
|
const loadContent = (attempt) => {
|
|
const file = plugin.resolveLink(linkText, sourcePath);
|
|
if (file) {
|
|
// guard against cycles and runaway nesting across native
|
|
// editors, where CM state can't carry ancestry — the DOM can
|
|
const domAnc = domAncestorPaths(wrap);
|
|
const allAnc = ancestors.concat(domAnc);
|
|
if (allAnc.includes(file.path) || domAnc.length >= MAX_DEPTH) {
|
|
const msg = contentEl.appendChild(document.createElement("div"));
|
|
msg.className = "inline-note-error";
|
|
msg.textContent = allAnc.includes(file.path)
|
|
? "Already open above — click to open in a tab."
|
|
: "Max nesting depth — click to open in a tab.";
|
|
msg.style.cursor = "pointer";
|
|
msg.addEventListener("click", () =>
|
|
app.workspace.openLinkText(linkText, sourcePath, false)
|
|
);
|
|
return;
|
|
}
|
|
wrap.dataset.notePath = file.path;
|
|
childSourcePath = file.path;
|
|
childAncestors = ancestors.concat([file.path]);
|
|
app.vault.read(file).then((content) => {
|
|
currentContent = content;
|
|
// long notes scroll inside a fixed-height body
|
|
wrap.classList.toggle("is-long", content.length > PREVIEW_LIMIT);
|
|
mountEditor(plugin.consumePendingFocus(linkText));
|
|
});
|
|
} else if (attempt < 10) {
|
|
window.setTimeout(() => loadContent(attempt + 1), 100);
|
|
} else {
|
|
const err = contentEl.appendChild(document.createElement("div"));
|
|
err.className = "inline-note-error";
|
|
err.textContent = "Note not found: " + linkText;
|
|
}
|
|
};
|
|
loadContent(0);
|
|
|
|
return {
|
|
el: wrap,
|
|
destroy: () => {
|
|
saveSelf();
|
|
clearBody();
|
|
},
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Shared editor extensions (main editor and every nested editor) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function buildDecorationField(plugin, getSourcePath, depth, getAncestors) {
|
|
const linkEditingMark = Decoration.mark({ class: "inline-note-link" });
|
|
const linkClickableMark = Decoration.mark({
|
|
class: "inline-note-link inline-note-link-clickable",
|
|
});
|
|
const hideMark = Decoration.replace({});
|
|
|
|
const selectionTouches = (state, from, to) => {
|
|
for (const r of state.selection.ranges) {
|
|
if (r.to >= from && r.from <= to) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const buildDecos = (state) => {
|
|
const decos = [];
|
|
const sourcePath = getSourcePath(state);
|
|
const ancestors = getAncestors(state);
|
|
const rendered = new Set();
|
|
|
|
const text = state.doc.toString();
|
|
|
|
// ![[embeds]] inside fallback editors render through Obsidian's
|
|
// MarkdownRenderer; raw text is shown while the cursor touches them
|
|
if (depth > 0) {
|
|
EMBED_RE.lastIndex = 0;
|
|
let em;
|
|
while ((em = EMBED_RE.exec(text)) !== null) {
|
|
const target = em[1].trim();
|
|
if (!target) continue;
|
|
const start = em.index;
|
|
const end = start + em[0].length;
|
|
if (!selectionTouches(state, start, end)) {
|
|
decos.push(
|
|
Decoration.replace({
|
|
widget: new EmbedWidget(plugin, target, sourcePath),
|
|
}).range(start, end)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
LINK_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = LINK_RE.exec(text)) !== null) {
|
|
// skip the [[...]] inside ![[...]] embeds
|
|
if (m.index > 0 && text[m.index - 1] === "!") continue;
|
|
const linkText = m[1].trim();
|
|
if (!linkText) continue;
|
|
const start = m.index;
|
|
const end = start + m[0].length;
|
|
|
|
// inside fallback editors there's no Obsidian markdown mode, so
|
|
// style [[links]] ourselves — like live preview, the brackets
|
|
// are hidden unless the cursor is inside the link
|
|
if (depth > 0) {
|
|
const revealed =
|
|
state.field(focusedField, false) && selectionTouches(state, start, end);
|
|
if (revealed) {
|
|
decos.push(linkEditingMark.range(start, end));
|
|
} else {
|
|
decos.push(hideMark.range(start, start + 2));
|
|
decos.push(linkClickableMark.range(start + 2, end - 2));
|
|
decos.push(hideMark.range(end - 2, end));
|
|
}
|
|
}
|
|
|
|
const file = plugin.resolveLink(linkText, sourcePath);
|
|
// only markdown notes are inline-able; links to PDFs, images,
|
|
// and other binary files keep Obsidian's native behavior —
|
|
// no button, no inline body, and never a raw binary read
|
|
const isMd = file
|
|
? file.extension === "md"
|
|
: !NON_MD_EXT_RE.test(linkText);
|
|
if (!isMd) continue;
|
|
|
|
const exists = !!file;
|
|
// no inline body for links that would recurse into an ancestor
|
|
// (self-links, A→B→A loops) or past the depth cap
|
|
const canInline = exists && depth < MAX_DEPTH && !ancestors.includes(file.path);
|
|
const inlineState = getInlineState(state, linkText);
|
|
const open =
|
|
canInline && inlineState !== "closed" && !rendered.has(linkText);
|
|
|
|
decos.push(
|
|
Decoration.widget({
|
|
widget: new LinkToggleWidget(plugin, linkText, sourcePath, depth, exists, open, canInline),
|
|
side: 1,
|
|
}).range(end)
|
|
);
|
|
|
|
if (open) {
|
|
rendered.add(linkText);
|
|
const line = state.doc.lineAt(end);
|
|
decos.push(
|
|
Decoration.widget({
|
|
widget: new InlineNoteWidget(
|
|
plugin,
|
|
linkText,
|
|
sourcePath,
|
|
depth + 1,
|
|
ancestors
|
|
),
|
|
side: 2,
|
|
block: true,
|
|
}).range(line.to)
|
|
);
|
|
}
|
|
}
|
|
return Decoration.set(decos, true);
|
|
};
|
|
|
|
return StateField.define({
|
|
create(state) {
|
|
return buildDecos(state);
|
|
},
|
|
update(value, tr) {
|
|
if (
|
|
tr.docChanged ||
|
|
// fallback editors reveal/hide brackets and embeds as the
|
|
// cursor moves or the editor gains/loses focus
|
|
(depth > 0 && tr.selection) ||
|
|
tr.effects.some((e) => e.is(setFocused) || e.is(setInlineState))
|
|
) {
|
|
return buildDecos(tr.state);
|
|
}
|
|
return value;
|
|
},
|
|
provide: (field) => EditorView.decorations.from(field),
|
|
});
|
|
}
|
|
|
|
function buildToggleKeymap(plugin, getSourcePath, depth) {
|
|
return Prec.high(
|
|
keymap.of([
|
|
{
|
|
// Tab immediately after "]]": toggle the inline note —
|
|
// create+open if missing. Never fires elsewhere, so
|
|
// indentation is safe.
|
|
key: "Tab",
|
|
run: (view) => {
|
|
const linkText = linkAt(view.state, view.state.selection.main.head, true);
|
|
if (!linkText) return false;
|
|
plugin.toggleInlineNote(view, linkText, getSourcePath(view.state), depth);
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
// Shift+Tab: collapse every open inline note in this
|
|
// editor. Falls through (list outdent etc.) when none
|
|
// are open.
|
|
key: "Shift-Tab",
|
|
run: (view) => plugin.collapseAllInline(view, getSourcePath(view.state)),
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
/* Clicking the empty space below an inline card that sits on the last
|
|
* line would otherwise do nothing — the card is a block widget after
|
|
* the final line, so there's no line there to receive the cursor.
|
|
* Create one. Inactive in every other situation, so normal editors
|
|
* keep their native click-below-content behavior. */
|
|
function buildClickBelowHandler(plugin) {
|
|
const lastLineHasOpenInline = (state) => {
|
|
const lastLine = state.doc.lineAt(state.doc.length);
|
|
LINK_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = LINK_RE.exec(lastLine.text)) !== null) {
|
|
if (m.index > 0 && lastLine.text[m.index - 1] === "!") continue;
|
|
const linkText = m[1].trim();
|
|
if (linkText && getInlineState(state, linkText) !== "closed") return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return EditorView.domEventHandlers({
|
|
mousedown: (e, view) => {
|
|
// a precise hit means the click landed on actual content
|
|
if (view.posAtCoords({ x: e.clientX, y: e.clientY }) != null) return false;
|
|
const docEnd = view.state.doc.length;
|
|
const endCoords = view.coordsAtPos(docEnd);
|
|
if (!endCoords || e.clientY <= endCoords.bottom) return false;
|
|
if (!lastLineHasOpenInline(view.state)) return false;
|
|
e.preventDefault();
|
|
view.dispatch({
|
|
changes: { from: docEnd, insert: "\n" },
|
|
selection: { anchor: docEnd + 1 },
|
|
scrollIntoView: true,
|
|
});
|
|
view.focus();
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildInlineExtensions(plugin, getSourcePath, depth, getAncestors) {
|
|
return [
|
|
inlineStateField,
|
|
buildDecorationField(plugin, getSourcePath, depth, getAncestors),
|
|
buildToggleKeymap(plugin, getSourcePath, depth),
|
|
buildClickBelowHandler(plugin),
|
|
];
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Settings */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class InlineNoteSettingTab extends PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display() {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
|
|
new Setting(containerEl)
|
|
.setName("Nesting style")
|
|
.setDesc("How inline note bodies are rendered.")
|
|
.addDropdown((dd) =>
|
|
dd
|
|
.addOption("line", "Vertical accent line")
|
|
.addOption("card", "Card (background only)")
|
|
.addOption("card-line", "Card + vertical accent line")
|
|
.setValue(this.plugin.settings.nestStyle)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.nestStyle = value;
|
|
this.plugin.applyNestStyle();
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Plugin */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class InlineNotePlugin extends Plugin {
|
|
async onload() {
|
|
this._pendingFocus = null;
|
|
// notes this plugin created this session — candidates for reclaim
|
|
// (deletion) if they're still empty when their inline body closes
|
|
this._createdInline = new Set();
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
this.applyNestStyle();
|
|
this.addSettingTab(new InlineNoteSettingTab(this.app, this));
|
|
|
|
const getRootSourcePath = (state) => {
|
|
const info = state.field(editorInfoField, false);
|
|
return (info && info.file && info.file.path) || "";
|
|
};
|
|
const getRootAncestors = (state) => {
|
|
const p = getRootSourcePath(state);
|
|
return p ? [p] : [];
|
|
};
|
|
this.registerEditorExtension(
|
|
buildInlineExtensions(this, getRootSourcePath, 0, getRootAncestors)
|
|
);
|
|
|
|
this.addCommand({
|
|
id: "toggle-inline-note-at-cursor",
|
|
name: "Toggle inline note for link under cursor",
|
|
editorCallback: async (editor, ctx) => {
|
|
const cursor = editor.getCursor();
|
|
const lineText = editor.getLine(cursor.line);
|
|
LINK_RE.lastIndex = 0;
|
|
let m;
|
|
let target = null;
|
|
while ((m = LINK_RE.exec(lineText)) !== null) {
|
|
if (m.index > 0 && lineText[m.index - 1] === "!") continue;
|
|
if (cursor.ch >= m.index && cursor.ch <= m.index + m[0].length) {
|
|
target = m[1].trim();
|
|
break;
|
|
}
|
|
}
|
|
if (!target) {
|
|
new Notice("No [[link]] under cursor.");
|
|
return;
|
|
}
|
|
const sourcePath = (ctx && ctx.file && ctx.file.path) || "";
|
|
if (editor.cm) this.toggleInlineNote(editor.cm, target, sourcePath, 0);
|
|
},
|
|
});
|
|
|
|
this.addCommand({
|
|
id: "collapse-all-inline-notes",
|
|
name: "Collapse all inline notes",
|
|
editorCallback: (editor, ctx) => {
|
|
const sourcePath = (ctx && ctx.file && ctx.file.path) || "";
|
|
if (editor.cm) this.collapseAllInline(editor.cm, sourcePath);
|
|
},
|
|
});
|
|
}
|
|
|
|
onunload() {
|
|
document.body.classList.remove(
|
|
"inline-note-style-line",
|
|
"inline-note-style-card",
|
|
"inline-note-style-card-line"
|
|
);
|
|
}
|
|
|
|
applyNestStyle() {
|
|
document.body.classList.remove(
|
|
"inline-note-style-line",
|
|
"inline-note-style-card",
|
|
"inline-note-style-card-line"
|
|
);
|
|
document.body.classList.add("inline-note-style-" + this.settings.nestStyle);
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
consumePendingFocus(linkText) {
|
|
if (this._pendingFocus === linkText) {
|
|
this._pendingFocus = null;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Collapse every open inline note in `view`. Returns true when at
|
|
* least one was open (so Shift+Tab can fall through otherwise). */
|
|
collapseAllInline(view, sourcePath) {
|
|
const text = view.state.doc.toString();
|
|
const toClose = [];
|
|
const seen = new Set();
|
|
LINK_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = LINK_RE.exec(text)) !== null) {
|
|
if (m.index > 0 && text[m.index - 1] === "!") continue;
|
|
const linkText = m[1].trim();
|
|
if (!linkText || seen.has(linkText)) continue;
|
|
seen.add(linkText);
|
|
if (getInlineState(view.state, linkText) !== "closed") toClose.push(linkText);
|
|
}
|
|
if (!toClose.length) return false;
|
|
view.dispatch({
|
|
effects: toClose.map((l) => setInlineState.of({ link: l, state: "closed" })),
|
|
});
|
|
view.focus();
|
|
// reclaim any still-empty notes we created (see toggleInlineNote)
|
|
window.setTimeout(() => {
|
|
for (const l of toClose) {
|
|
this.maybeReclaimEmpty(l, sourcePath, null).then((reclaimed) => {
|
|
if (reclaimed) {
|
|
view.dispatch({
|
|
effects: setInlineState.of({ link: l, state: "closed" }),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}, 600);
|
|
return true;
|
|
}
|
|
|
|
/** Toggle the inline note: create+open if missing, otherwise
|
|
* open ↔ close. */
|
|
async toggleInlineNote(view, linkText, sourcePath, depth) {
|
|
if (depth >= MAX_DEPTH) {
|
|
new Notice("Inline Note: max nesting depth reached — opening in a tab.");
|
|
this.app.workspace.openLinkText(linkText, sourcePath, false);
|
|
return;
|
|
}
|
|
try {
|
|
const existing = this.resolveLink(linkText, sourcePath);
|
|
// non-markdown targets (images, PDFs, …) are never inlined
|
|
if (existing && existing.extension !== "md") {
|
|
this.app.workspace.openLinkText(linkText, sourcePath, false);
|
|
return;
|
|
}
|
|
if (!existing && NON_MD_EXT_RE.test(linkText)) {
|
|
new Notice("Inline Note: not a markdown note.");
|
|
return;
|
|
}
|
|
const existedBefore = !!existing;
|
|
await this.ensureFile(linkText, sourcePath);
|
|
const current = getInlineState(view.state, linkText);
|
|
|
|
const next =
|
|
!existedBefore || current === "closed" ? "preview" : "closed";
|
|
|
|
// focus the editor when opening via keyboard so typing can
|
|
// start immediately
|
|
if (next === "preview") this._pendingFocus = linkText;
|
|
view.dispatch({ effects: setInlineState.of({ link: linkText, state: next }) });
|
|
// Collapsing destroys the nested editor; if it held keyboard
|
|
// focus, focus would silently fall to <body>, which other
|
|
// plugins (e.g. graph preview panes watching focusin/focusout)
|
|
// read as "the user left the pane". Hand focus to the editor
|
|
// that owns the link instead.
|
|
if (next === "closed") {
|
|
view.focus();
|
|
// If this was a note we created and it's still empty,
|
|
// remove it again — an accidental click shouldn't leave
|
|
// stray blank notes. Wait out the body's closing save
|
|
// first so freshly typed text is never misjudged.
|
|
window.setTimeout(() => {
|
|
this.maybeReclaimEmpty(linkText, sourcePath, null).then((reclaimed) => {
|
|
if (reclaimed) {
|
|
view.dispatch({
|
|
effects: setInlineState.of({ link: linkText, state: "closed" }),
|
|
});
|
|
}
|
|
});
|
|
}, 600);
|
|
}
|
|
} catch (err) {
|
|
new Notice("Inline Note: could not create note — " + err.message);
|
|
}
|
|
}
|
|
|
|
/** Resolve a link target, falling back to a direct path lookup while
|
|
* the metadata cache catches up on a freshly created file. */
|
|
resolveLink(linkText, sourcePath) {
|
|
const viaCache = this.app.metadataCache.getFirstLinkpathDest(linkText, sourcePath);
|
|
if (viaCache) return viaCache;
|
|
const folder = this.app.fileManager.getNewFileParent(sourcePath);
|
|
const path = (folder.path === "/" ? "" : folder.path + "/") + linkText + ".md";
|
|
const direct = this.app.vault.getFileByPath
|
|
? this.app.vault.getFileByPath(path)
|
|
: this.app.vault.getAbstractFileByPath(path);
|
|
return direct || null;
|
|
}
|
|
|
|
/** Create the linked note (as a real file) if it doesn't exist yet. */
|
|
async ensureFile(linkText, sourcePath) {
|
|
const existing = this.resolveLink(linkText, sourcePath);
|
|
if (existing) return existing;
|
|
const folder = this.app.fileManager.getNewFileParent(sourcePath);
|
|
const path = (folder.path === "/" ? "" : folder.path + "/") + linkText + ".md";
|
|
const created = await this.app.vault.create(path, "");
|
|
this._createdInline.add(created.path);
|
|
return created;
|
|
}
|
|
|
|
/** If `linkText` resolves to a note this plugin created this session
|
|
* and it's (still) empty, move it to trash. Never touches notes the
|
|
* plugin didn't create. Returns true when the note was reclaimed. */
|
|
async maybeReclaimEmpty(linkText, sourcePath, knownContent) {
|
|
const file = this.resolveLink(linkText, sourcePath);
|
|
if (!file || file.extension !== "md") return false;
|
|
if (!this._createdInline.has(file.path)) return false;
|
|
let content = knownContent;
|
|
if (content == null) {
|
|
try {
|
|
content = await this.app.vault.read(file);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
if (content.trim() !== "") return false;
|
|
this._createdInline.delete(file.path);
|
|
if (this.app.fileManager.trashFile) {
|
|
await this.app.fileManager.trashFile(file);
|
|
} else {
|
|
await this.app.vault.trash(file, true);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
module.exports = InlineNotePlugin;
|