mirror of
https://github.com/scotttomaszewski/obsidian-disciples-journal.git
synced 2026-07-22 05:42:13 +00:00
feat: verse selection controller, action bar, actions, note picker
This commit is contained in:
parent
6fb1b08ea3
commit
fb34d464d8
5 changed files with 378 additions and 0 deletions
21
src/components/InsertTargetModal.ts
Normal file
21
src/components/InsertTargetModal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/components/VerseActionBar.ts
Normal file
111
src/components/VerseActionBar.ts
Normal 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` });
|
||||
}
|
||||
}
|
||||
83
src/components/VerseActions.ts
Normal file
83
src/components/VerseActions.ts
Normal 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();
|
||||
}
|
||||
161
src/components/VerseSelectionController.ts
Normal file
161
src/components/VerseSelectionController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue