yaye-work_inline-note/main.js
yaye.work 3f0585e9dd Click below a trailing inline card to continue the parent note
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.
2026-07-20 19:04:10 +10:00

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;