diff --git a/src/components/InsertTargetModal.ts b/src/components/InsertTargetModal.ts new file mode 100644 index 0000000..826d21a --- /dev/null +++ b/src/components/InsertTargetModal.ts @@ -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 { + constructor(app: App, private onChoose: (file: TFile) => void | Promise) { + 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); + } +} diff --git a/src/components/VerseActionBar.ts b/src/components/VerseActionBar.ts new file mode 100644 index 0000000..048ec1b --- /dev/null +++ b/src/components/VerseActionBar.ts @@ -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 = { + 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` }); + } +} diff --git a/src/components/VerseActions.ts b/src/components/VerseActions.ts new file mode 100644 index 0000000..bc1a8a1 --- /dev/null +++ b/src/components/VerseActions.ts @@ -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( + `.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 { + 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(); +} diff --git a/src/components/VerseSelectionController.ts b/src/components/VerseSelectionController.ts new file mode 100644 index 0000000..ef53b15 --- /dev/null +++ b/src/components/VerseSelectionController.ts @@ -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(".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(".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; + } + } +} diff --git a/src/core/VerseSelectionService.ts b/src/core/VerseSelectionService.ts index 21c7039..3542e95 100644 --- a/src/core/VerseSelectionService.ts +++ b/src/core/VerseSelectionService.ts @@ -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 {