feat: verse selection controller, action bar, actions, note picker

This commit is contained in:
Scott Tomaszewski 2026-06-02 22:16:45 -04:00
parent 6fb1b08ea3
commit fb34d464d8
5 changed files with 378 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import { App, FuzzySuggestModal, TFile } from "obsidian";
/** Fuzzy-pick a markdown note to append the selection to. */
export class InsertTargetModal extends FuzzySuggestModal<TFile> {
constructor(app: App, private onChoose: (file: TFile) => void | Promise<void>) {
super(app);
this.setPlaceholder("Append to note…");
}
getItems(): TFile[] {
return this.app.vault.getMarkdownFiles();
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
void this.onChoose(file);
}
}

View file

@ -0,0 +1,111 @@
import { Component, Menu, setIcon } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
import { InsertFormat, runVerseAction, VerseActionKind } from "./VerseActions";
const FORMAT_LABEL: Record<InsertFormat, string> = {
inline: "Ref",
codeblock: "Block",
blockquote: "Quote",
};
const FORMATS: InsertFormat[] = ["inline", "codeblock", "blockquote"];
export class VerseActionBar extends Component {
private root: HTMLElement | null = null;
constructor(
private plugin: DisciplesJournalPlugin,
private passageEl: HTMLElement,
private onClose: () => void,
) {
super();
}
onunload(): void {
this.root?.remove();
this.root = null;
}
render(selection: VerseSelection): void {
const doc = this.passageEl.doc;
this.root?.remove();
const bar = doc.body.createDiv({ cls: "dj-verse-action-bar" });
this.root = bar;
bar.createSpan({ cls: "dj-verse-action-label", text: selection.label() });
const actions: { kind: VerseActionKind; label: string }[] = [
{ kind: "copy", label: "Copy" },
{ kind: "insert", label: "Insert" },
];
if (this.plugin.settings.enableAppendToNote) {
actions.push({ kind: "append", label: "Append to note…" });
}
const style = this.plugin.settings.formatChooserStyle;
const defFormat = this.plugin.settings.defaultInsertFormat;
if (style === "toggle") {
let format: InsertFormat = defFormat;
const toggle = bar.createDiv({ cls: "dj-format-toggle" });
for (const f of FORMATS) {
const btn = toggle.createEl("button", { text: FORMAT_LABEL[f] });
btn.toggleClass("is-active", f === format);
btn.onClickEvent(() => {
format = f;
toggle.findAll("button").forEach((b) => b.removeClass("is-active"));
btn.addClass("is-active");
});
}
for (const a of actions) {
const btn = bar.createEl("button", { text: a.label });
btn.onClickEvent(() => void runVerseAction(this.plugin, a.kind, selection, format, this.passageEl));
}
} else if (style === "submenu") {
for (const a of actions) {
const btn = bar.createEl("button", { text: a.label });
btn.onClickEvent((e) => this.openFormatMenu(e, a.kind, a.label, selection));
}
} else {
// split: body = default format, chevron = other formats
for (const a of actions) {
const group = bar.createDiv({ cls: "dj-split-button" });
const main = group.createEl("button", { cls: "dj-split-main", text: a.label });
main.onClickEvent(() => void runVerseAction(this.plugin, a.kind, selection, defFormat, this.passageEl));
const chevron = group.createEl("button", {
cls: "dj-split-chevron",
attr: { "aria-label": `${a.label}: choose format` },
});
setIcon(chevron, "chevron-down");
chevron.onClickEvent((e) => this.openFormatMenu(e, a.kind, a.label, selection));
}
}
const close = bar.createEl("button", {
cls: "dj-verse-action-close",
attr: { "aria-label": "Clear verse selection" },
});
setIcon(close, "x");
close.onClickEvent(() => this.onClose());
this.position();
}
private openFormatMenu(e: MouseEvent, kind: VerseActionKind, label: string, selection: VerseSelection): void {
const menu = new Menu();
for (const f of FORMATS) {
menu.addItem((item) =>
item.setTitle(`${label}: ${FORMAT_LABEL[f]}`)
.onClick(() => void runVerseAction(this.plugin, kind, selection, f, this.passageEl)));
}
menu.showAtMouseEvent(e);
}
private position(): void {
if (!this.root) return;
const r = this.passageEl.getBoundingClientRect();
// Anchor just below the passage; CSS bottom-docks it on narrow layouts.
this.root.setCssStyles({ left: `${r.left}px`, top: `${r.bottom + 4}px` });
}
}

View file

@ -0,0 +1,83 @@
import { MarkdownView, Notice, TFile } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import { formatBlockquote, formatCodeBlock, formatInlineReference } from "../utils/VerseFormatter";
import { InsertTargetModal } from "./InsertTargetModal";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
export type InsertFormat = "inline" | "codeblock" | "blockquote";
export type VerseActionKind = "copy" | "insert" | "append";
/**
* Build the markdown payload for a selection in the chosen format. `sourceEl` is the
* rendered passage the selection came from only the blockquote format reads it (to
* pull the visible verse text in its own document, which keeps popout windows correct).
*/
export function buildPayload(
plugin: DisciplesJournalPlugin,
selection: VerseSelection,
format: InsertFormat,
sourceEl: HTMLElement,
): string {
const label = selection.label();
if (format === "inline") return formatInlineReference(label);
if (format === "codeblock") return formatCodeBlock(label);
const text = extractSelectedText(selection, sourceEl);
return formatBlockquote(label, text, plugin.settings.preferredBibleVersion);
}
/**
* Pull the visible text of the selected verses out of the rendered passage, reading
* the `.dj-verse` spans the wrapper created (skipping verse-number markers/footnotes).
*/
function extractSelectedText(selection: VerseSelection, sourceEl: HTMLElement): string {
const parts: string[] = [];
for (const { chapter, verse } of selection.verseList()) {
const spans = sourceEl.querySelectorAll<HTMLElement>(
`.dj-verse[data-chapter="${chapter}"][data-verse="${verse}"]`,
);
let text = "";
spans.forEach((span) => {
const clone = span.cloneNode(true) as HTMLElement;
clone.querySelectorAll(".verse-num, .chapter-num, .footnote, .footnotes").forEach((n) => n.remove());
text += clone.textContent ?? "";
});
const trimmed = text.replace(/\s+/g, " ").trim();
if (trimmed) parts.push(trimmed);
}
return parts.join(" ");
}
export async function runVerseAction(
plugin: DisciplesJournalPlugin,
kind: VerseActionKind,
selection: VerseSelection,
format: InsertFormat,
sourceEl: HTMLElement,
): Promise<void> {
const payload = buildPayload(plugin, selection, format, sourceEl);
if (kind === "copy") {
await navigator.clipboard.writeText(payload);
new Notice(`Copied ${selection.label()}`);
return;
}
if (kind === "insert") {
const editor = plugin.app.workspace.getActiveViewOfType(MarkdownView)?.editor;
if (!editor) {
new Notice("Open a note and place your cursor to insert.");
return;
}
editor.replaceSelection(payload);
return;
}
// append: pick a note and add to its end (background edit → Vault.process)
new InsertTargetModal(plugin.app, async (file: TFile) => {
await plugin.app.vault.process(file, (data) => {
const sep = data.length === 0 || data.endsWith("\n") ? "" : "\n";
return `${data}${sep}\n${payload}\n`;
});
new Notice(`Appended ${selection.label()} to ${file.basename}`);
}).open();
}

View file

@ -0,0 +1,161 @@
import { Component } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import { VerseRef } from "../utils/VerseId";
import { VerseSelectionService, SelectionOwner } from "../core/VerseSelectionService";
import { VerseActionBar } from "./VerseActionBar";
import { wrapPassageVerses } from "./VerseWrapper";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
let nextId = 0;
const LONG_PRESS_MS = 400;
const MOVE_CANCEL_PX = 10;
export class VerseSelectionController extends Component implements SelectionOwner {
readonly id = `dj-passage-${nextId++}`;
private selection: VerseSelection;
private anchor: VerseRef | null = null;
private bar: VerseActionBar | null = null;
// touch state
private pressTimer: number | null = null;
private dragging = false;
private touchStart: { x: number; y: number } | null = null;
constructor(
private plugin: DisciplesJournalPlugin,
public readonly sourceEl: HTMLElement,
private book: string,
private service: VerseSelectionService,
) {
super();
this.selection = new VerseSelection(book);
}
onload(): void {
wrapPassageVerses(this.sourceEl);
this.sourceEl.addClass("dj-selectable");
this.registerDomEvent(this.sourceEl, "click", (e) => this.onClick(e));
this.registerDomEvent(this.sourceEl, "touchstart", (e) => this.onTouchStart(e), { passive: true });
this.registerDomEvent(this.sourceEl, "touchmove", (e) => this.onTouchMove(e), { passive: false });
this.registerDomEvent(this.sourceEl, "touchend", () => this.onTouchEnd());
this.register(this.service.onChange(() => this.reflect()));
}
onunload(): void {
// If this passage owned the selection (e.g. note re-rendered), drop it.
this.service.clearIfOwner(this);
this.clearPressTimer();
}
private verseAt(node: Element | null): { ref: VerseRef } | null {
const el = node?.closest<HTMLElement>(".dj-verse") ?? null;
if (!el || !this.sourceEl.contains(el)) return null;
const chapter = Number(el.dataset.chapter);
const verse = Number(el.dataset.verse);
if (!chapter || !verse) return null;
return { ref: { chapter, verse } };
}
private onClick(e: MouseEvent): void {
const hit = this.verseAt(e.target instanceof Element ? e.target : null);
if (!hit) return;
e.preventDefault();
if (e.shiftKey && this.anchor) {
this.selection.selectRange(this.anchor, hit.ref);
} else {
this.selection.toggle(hit.ref);
this.anchor = hit.ref;
}
this.commit();
}
private onTouchStart(e: TouchEvent): void {
const hit = this.verseAt(e.target instanceof Element ? e.target : null);
if (!hit) return;
const t = e.touches[0];
this.touchStart = { x: t.clientX, y: t.clientY };
this.anchor = hit.ref;
this.pressTimer = this.sourceEl.win.setTimeout(() => {
this.dragging = true; // long-press engaged: subsequent moves select, not scroll
}, LONG_PRESS_MS);
}
private onTouchMove(e: TouchEvent): void {
if (!this.touchStart) return;
const t = e.touches[0];
if (!this.dragging) {
// Moved before the long-press fired → treat as a scroll, cancel selection.
const moved = Math.hypot(t.clientX - this.touchStart.x, t.clientY - this.touchStart.y);
if (moved > MOVE_CANCEL_PX) this.clearPressTimer();
return;
}
e.preventDefault(); // we own the gesture now; stop the page scrolling
const under = this.sourceEl.doc.elementFromPoint(t.clientX, t.clientY);
const hit = this.verseAt(under);
if (hit && this.anchor) {
this.selection.selectRange(this.anchor, hit.ref);
this.commit();
}
}
private onTouchEnd(): void {
const wasDragging = this.dragging;
this.clearPressTimer();
if (!wasDragging && this.anchor) {
// short tap → toggle a single verse
this.selection.toggle(this.anchor);
this.commit();
}
this.dragging = false;
this.touchStart = null;
}
private clearPressTimer(): void {
if (this.pressTimer !== null) {
this.sourceEl.win.clearTimeout(this.pressTimer);
this.pressTimer = null;
}
this.dragging = false;
}
/** Push our selection into the shared service (it decides ownership). */
private commit(): void {
this.service.set(this.selection, this);
}
/** Re-render highlight + bar from the service's current state. */
private reflect(): void {
const active = this.service.get();
const owned = active?.owner.id === this.id ? active.selection : null;
this.sourceEl.querySelectorAll<HTMLElement>(".dj-verse").forEach((el) => {
const ref = { chapter: Number(el.dataset.chapter), verse: Number(el.dataset.verse) };
el.toggleClass("dj-verse-selected", !!owned && owned.has(ref));
});
if (owned) {
if (!this.bar) {
this.bar = new VerseActionBar(this.plugin, this.sourceEl, () => this.clearSelection());
this.addChild(this.bar);
}
this.bar.render(owned);
} else {
this.hideBar();
}
}
clearSelection(): void {
this.selection.clear();
this.anchor = null;
this.service.clearIfOwner(this);
}
private hideBar(): void {
if (this.bar) {
this.removeChild(this.bar);
this.bar = null;
}
}
}

View file

@ -4,6 +4,8 @@ import { VerseSelection } from "./VerseSelection";
/** Anything that can own the current selection (a per-passage controller). */
export interface SelectionOwner {
readonly id: string;
/** The rendered passage element — used to extract verse text in the right document. */
readonly sourceEl: HTMLElement;
}
interface ActiveSelection {