Merge verse selection & insert-into-note feature

This commit is contained in:
Scott Tomaszewski 2026-06-02 22:25:46 -04:00
commit f42759c0e2
27 changed files with 3013 additions and 10 deletions

View file

@ -52,6 +52,10 @@ Source is organized under `src/` by responsibility:
display setting, then delegates to the renderer and handles errors.
- **`BibleEventHandlers.ts`** — the single, plugin-owned hover/popup lifecycle
(mouseover/mouseout, show/hide timing). Loaded as a child component.
- **`VerseSelection.ts`** — pure value object: a verse set within one book →
contiguous `BibleReference[]` runs + a display label (`Genesis 1:2-3, 5`).
- **`VerseSelectionService.ts`** — plugin-owned (`addChild`) holder of the *single*
active verse selection across panes + its owning controller; notifies subscribers.
### `services/` — content resolution and storage
@ -82,6 +86,18 @@ Source is organized under `src/` by responsibility:
window has its own `Document`, so styles are applied per-doc).
- **`BookSuggest.ts`**, **`OpenBibleModal.ts`** — the `Open Bible` modal and its
book autocomplete.
- **`VerseWrapper.ts`** — `wrapPassageVerses(passageEl)`: wraps each rendered verse in a
`.dj-verse` span (idempotent) so verses are selectable/highlightable.
- **`VerseSelectionController.ts`** — one per rendered passage: wraps verses, binds
gestures (desktop tap/shift, mobile long-press-drag), reflects the service's selection
(highlight) and owns the action bar.
- **`VerseActionBar.ts`** — floating bar shown while verses are selected; renders the
Copy / Insert (and optional Append) actions with the configurable `split`/`toggle`/
`submenu` format chooser.
- **`VerseActions.ts`** — turns a selection + format into the payload and performs Copy
(clipboard), Insert (Editor API), or Append (`Vault.process`); extracts verse text for
the blockquote format from the passage's own document.
- **`InsertTargetModal.ts`** — `FuzzySuggestModal<TFile>` note picker for "Append to note…".
### `utils/` — small helpers
@ -90,6 +106,9 @@ Source is organized under `src/` by responsibility:
- **`BiblePassage.ts`** — `{ reference, html }` pairing for resolved content.
- **`BibleApiResponse`/`BiblePassage`** are what flow back out of the content
service to the renderer.
- **`VerseId.ts`** — `parseVerseId(id)`: ESV marker id (`v01001002-1`) → `{chapter, verse}`.
- **`VerseFormatter.ts`** — pure builders for the three insert formats (inline reference,
`bible` code block, blockquote-with-text + citation).
- **`FrontmatterUtil.ts`** — apply user-configured custom frontmatter to Bible
notes (`getCustomFrontmatterForReference`, `applyCustomFrontmatter`).
- **`BibleCodeblockFormatter.ts`** — formatting helper for the `bible` code block.
@ -115,7 +134,20 @@ note renders
```
Full passages follow the same resolution path through `getBibleContent`, but render
the whole passage inline (`processFullBiblePassage`) with navigation + heading.
the whole passage inline (`processFullBiblePassage`) with navigation + heading. Non-
contiguous references (`Genesis 1:2-3, 5`) resolve via `getBibleContentList`, which parses
the list with `BibleReference.parseList` and concatenates each run's HTML.
## Verse selection
After a full passage renders, `processFullBiblePassage` wraps its verses
(`wrapPassageVerses` → `.dj-verse` spans) and attaches a `VerseSelectionController`.
Selecting verses (tap/shift on desktop, long-press-drag on mobile) updates a per-passage
`VerseSelection` and pushes it to the plugin-owned `VerseSelectionService`, which holds the
single active selection. The owning controller highlights its verses and shows a
`VerseActionBar`; Copy / Insert / Append (and the editor `Insert … here` menu + commands)
run through `VerseActions`, formatting via `VerseFormatter`. See
[docs/gotchas.md](docs/gotchas.md) for the wrapping/gesture/bar-lifecycle details.
## Storage model

View file

@ -6,6 +6,13 @@ header text is **exactly** the release tag (no leading `v`).
## Unreleased
- Adds verse selection: tap verses in a rendered passage to select them (shift-click or
long-press-drag for a range), then copy or insert them as an inline reference, a `bible`
code block, or a blockquote of the verse text. A floating action bar (with configurable
split / toggle / submenu format choosers) and a desktop right-click "Insert … here" both
drive it; an optional "Append to note…" action can be enabled in settings. Adds a
*Verse selection* settings section and supports non-contiguous references like
`Genesis 1:2-3, 5`
- Fixes chapter-range references (e.g. `Genesis 1-2`): `BibleReference.parse` was
storing the end chapter in the `endVerse` field, so the range was dropped — it
round-tripped to `Genesis 1` and was fetched/stored as a single chapter. The end

View file

@ -10,6 +10,14 @@ passages inside notes (inline hover previews + full `bible` code blocks) and can
download passages on demand from the ESV API. See `README.md` for the user-facing
feature tour and `manifest.json` for plugin metadata.
## Design ethos
**Non-intrusive by default.** People use this plugin during devotion, prayer, and small
group. Features must never distract, frustrate, or shake the user out of an intimate time
with God. Default to quiet: nothing new appears, moves, or interrupts until the user
deliberately asks for it. No text shifting, no surprise popups, no persistent chrome. When
in doubt, do less.
## Where to look
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the plugin is wired: services,

View file

@ -82,3 +82,13 @@ across existing notes.
All failures funnel through `BibleApiResponse.error(message, ErrorType)`
(`ApiAuthentication`, `BadApiResponse`, `RequestsForbidden`, …). User-visible cases
surface an Obsidian `Notice`; internal ones are logged to the console.
## Attribution when reproducing verse text
The verse-selection **blockquote** format (`VerseFormatter.formatBlockquote`) reproduces
ESV text into the user's note and appends a `— <ref> (ESV)` citation. The ESV API
[copyright/permissions terms](https://api.esv.org/) require attribution when ESV text is
quoted; the `(ESV)` suffix is the minimum, and larger reproductions carry a fuller
copyright-notice obligation. If the quoting story expands (e.g. exporting many passages),
revisit the notice requirement here. The other two formats (inline reference, `bible`
block) store only a reference, not text, so they don't trigger this.

View file

@ -39,3 +39,32 @@ uses a `MutationObserver` to scroll the instant the `.verse-N` element appears,
with a **5000 ms** fallback that disconnects the observer if it never does (wrong
ref, or an edit mode that produces no verse elements). Don't replace this with a
fixed `setTimeout`. (`BibleFiles.ts:118-145`.)
## Verse selection: wrapping, gestures, and bar lifecycle
Verse selection is layered onto rendered passages by `VerseSelectionController` (one per
full `bible` passage, attached in `BibleReferenceRenderer.processFullBiblePassage`). A few
non-obvious things:
- **Verses aren't pre-wrapped.** ESV HTML marks a verse only with a
`<b class="verse-num|chapter-num" id="vBBCCCVVV-N">`; the verse's text then runs as loose
inline nodes until the next marker, sometimes across `<p>` boundaries.
`wrapPassageVerses` (`VerseWrapper.ts`) walks each block and wraps each verse's run in a
`<span class="dj-verse" data-chapter data-verse>`. It's **idempotent** (bails if a
`.dj-verse` already exists) and a verse split across paragraphs yields two spans sharing
the same `data-verse` — both highlight together. `parseVerseId` ignores the book digits;
the book comes from the passage's canonical reference.
- **Mobile selection vs. scroll.** Touch uses a long-press threshold (`LONG_PRESS_MS`): a
quick swipe scrolls normally; only a deliberate long-press engages drag-select (which
then calls `preventDefault` on `touchmove`). A short tap toggles a single verse. Don't
remove the threshold or you'll hijack scrolling.
- **One selection, globally.** `VerseSelectionService` (plugin-owned, `addChild`) holds a
single active selection + its owning controller. Only the owner renders highlight and the
action bar, so selecting in one passage clears another.
- **The action bar is per-`Document` and `fixed`-positioned.** It's appended to the
passage's own `doc.body` (popout-safe) and positioned from the passage's
`getBoundingClientRect`. Blockquote text is extracted from the owner's `sourceEl` (its
own document) for the same reason — never query the global `document`.
- **Selection clears when its passage unloads.** `controller.onunload` calls
`service.clearIfOwner(this)`, so a note re-render or pane close drops a stale selection
and its bar.

View file

@ -44,3 +44,19 @@ provides:
These fields also drive note filenames — see
[esv-api.md](esv-api.md) and `BibleFiles.pathForPassage`.
## Non-contiguous (comma) lists
Verse selection (see [gotchas.md](gotchas.md)) can produce non-contiguous references
like `Genesis 1:2-3, 5` or `Genesis 1:31, 2:1`. These are parsed by
`BibleReference.parseList`, **not** the single-range `parse`:
- The first comma-separated item is a full reference (any format above).
- Later items may be a bare verse (`5`), a bare verse range (`5-7`) — both inheriting the
previous item's book **and** chapter — or `chapter:verse[-end]`, inheriting only the book.
- The result is a `BibleReference[]` (one per contiguous run); any invalid item makes the
whole list `null`.
A rendered list resolves each run and concatenates the HTML
(`BibleContentService.getBibleContentList`); the passage heading/link uses the **first**
run.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,213 @@
# Verse Selection & Insert-into-Note — Design
## Context
Today the plugin renders Bible passages (full `bible` code blocks) but offers no way
to *act* on the verses inside them. The user wants to select one or more verses from a
rendered passage and pull them into a note — fluidly, the way they journal (inline
reference mid-sentence), study (full code block), or share (blockquote with the actual
text). This must work across multiple open notes/windows, on desktop **and** mobile,
and must never feel intrusive: users are often in devotion, prayer, or small group, and
the feature should stay completely invisible until they choose to act.
This is also the **foundation** for a future feature ("select verses → find all notes
that reference them"), so verse selection is being designed as a clean, reusable
primitive with a small action registry, not a one-off.
## Design ethos (new, to be recorded in CLAUDE.md)
A `## Design ethos` section will be added inline to `CLAUDE.md`:
> **Non-intrusive by default.** People use this plugin during devotion, prayer, and
> small group. Features must never distract, frustrate, or shake the user out of an
> intimate time with God. Default to quiet: nothing new appears, moves, or interrupts
> until the user deliberately asks for it. No text shifting, no surprise popups, no
> persistent chrome. When in doubt, do less.
Every decision below follows from this: selection adds zero visible chrome until a verse
is tapped; the action bar exists only while a selection exists; insertion never happens
automatically.
## Interaction model
**Selection (inside a rendered `bible` passage):**
- **Desktop tap / click a verse** → toggles that verse (non-contiguous by default: tap
v2 then v5 selects exactly 2 and 5). Selected verses get a subtle inline highlight
that follows the text across line wraps (verses share lines, so the highlight is on an
inline run, not a line).
- **Desktop Shift+click a verse** → selects the contiguous range from the anchor (last
toggled verse) through the clicked verse.
- **Mobile short tap** → toggles a single verse.
- **Mobile long-press (≈400 ms) then drag** → initiates a range selection and extends it
as the finger moves. A press threshold guards against hijacking normal scrolling; a
quick swipe scrolls as usual, only a deliberate long-press enters selection-drag.
- Selection **persists** across panes/windows until explicitly cleared (the bar's ✕, the
`Esc` key, or a "Clear selection" command). Inserting does **not** auto-clear, so the
user can insert the same selection in several places. Selection clears if its passage
element is destroyed/re-rendered.
**Acting on a selection — same actions surfaced in three places:**
1. **Floating action bar** — appears (anchored near the selection; bottom-docked on
narrow/mobile layouts) only while ≥1 verse is selected. Carries the reference label,
a ✕, and the **Copy** and **Insert** actions (plus **Append to note…** *only when the
user enables it* — see below). Same on desktop and mobile (the bar is the primary
surface on mobile, which has no right-click). **How format is chosen on the bar is
configurable** (`formatChooserStyle` setting) — all three presentations are implemented
and the user picks one:
- **`split`** (default) — split buttons `[Copy ▾] [Insert ▾]`; the body uses the
default format, the ▾ chevron opens the other two.
- **`toggle`** — a segmented format toggle `[ Ref | Block | Quote ]` plus plain action
buttons `[Copy] [Insert]`; pick the format once, then an action.
- **`submenu`** — plain action buttons, each of which opens a format submenu.
2. **Desktop right-click on the passage** → context menu with the same Copy / Insert
actions (plus Append to note… when enabled), each with a format submenu.
3. **Desktop right-click inside any editor** (while a selection exists) → an
"Insert `<ref>` here" entry that inserts at the click point — the precise, reverse-flow
path that sidesteps the multi-window problem because the user placed their own cursor.
**Locations for insertion:**
- **Insert at cursor** — drops into the last-focused markdown editor at its cursor (bar
button + a hotkey-able command). Primary path.
- **Right-click in editor → "Insert `<ref>` here"** — inserts at the exact click point.
- **Append to note…** *(optional, off by default)* — a fuzzy note-picker (`FuzzySuggestModal`
over markdown `TFile`s) → appends to the end of the chosen note. This overlaps with
Insert-at-cursor, so it ships disabled and is enabled via `enableAppendToNote`; only then
does it appear on the bar / context menu and as a command.
**Three output formats (chosen per-insert; a configurable default sets the button body):**
- **Inline reference** — `` `Genesis 1:2-3, 5` `` (renders with the existing hover preview).
- **`bible` code block** — fenced block of the same reference (renders as the full passage).
- **Blockquote with text** — the actual verse text as a markdown blockquote ending in a
`— Genesis 1:2-3, 5 (ESV)` citation; self-contained / portable to other apps.
## Non-contiguous references
`BibleReference` (`src/core/BibleReference.ts`) models only a single contiguous range and
its `parse` rejects comma lists. Because non-contiguous is the default selection behavior,
we need to represent and render `Genesis 1:2-3, 5`:
- **Selection state** is a set of `{chapter, verse}` within the passage. A new
`VerseSelection` value object collapses the set into **contiguous runs**, each a normal
`BibleReference`, plus a combined display label (`"Genesis 1:2-3, 5"`,
cross-chapter-aware). This keeps `BibleReference` single-range — a multi-run selection is
just `BibleReference[]`.
- **Blockquote** needs no parser change: verse text is extracted from the already-rendered
DOM verse-spans (see wrapping below); citation uses the combined label.
- **Inline reference & code block** must round-trip render. Extend the parse/resolve path
to accept a comma-separated verse list within a chapter → `BibleReference[]`, and have
the content service resolve each run (ESV API `q=` accepts comma lists; runs can also be
resolved/concatenated individually from cache). Documented in `docs/reference-formats.md`.
## Architecture
**Verse wrapping (the enabling primitive).** ESV HTML marks verses only with
`<b class="verse-num" id="v01001002-1">` (and `chapter-num` for the first verse); a verse's
text runs as loose inline nodes until the next marker, sometimes across `<p>` boundaries.
After `processFullBiblePassage` builds `passageEl`
(`src/components/BibleReferenceRenderer.ts:134`), a new pass walks each paragraph's child
nodes, opens a new `<span class="dj-verse" data-book data-chapter data-verse>` at each
verse-num/chapter-num marker, and collects following siblings until the next marker. The id
(`v BB CCC VVV -N`) is parsed to book/chapter/verse (ignore the `-N` suffix). A verse split
across paragraphs yields two spans sharing the same `data-verse`; both highlight together.
This same `data-*` tagging is what the future "find referencing notes" feature will key on.
**New modules (`src/`):**
- `core/VerseSelectionService.ts` — plugin-owned (added via `addChild`), holds the current
`VerseSelection`, emits change events, exposes clear(). Single source of truth across panes.
- `core/VerseSelection.ts` — value object: verse set → contiguous `BibleReference[]` runs +
combined label; pure, unit-tested.
- `components/VerseSelectionController.ts` — attached per rendered passage: does the verse
wrapping, binds pointer/click/keyboard gestures (desktop tap/shift, mobile long-press+drag
with scroll guard), applies/removes highlight classes, reflects the service's selection.
- `components/VerseActionBar.ts` — the floating bar; shown/hidden by the service's
selection state; per-`Document` aware (popout windows each have their own doc, like
`BibleStyles`). Renders its format chooser in whichever mode `formatChooserStyle`
selects (`split` / `toggle` / `submenu`) — all three implemented.
- `components/InsertTargetModal.ts``FuzzySuggestModal<TFile>` note picker for the
optional "Append to note…" action (only used when `enableAppendToNote` is on).
- `utils/VerseFormatter.ts` — turns a `VerseSelection` into each of the three output strings
(inline ref, code block, blockquote+citation); pure, unit-tested.
**Touch points (existing):**
- `components/BibleReferenceRenderer.ts` — invoke wrapping + attach a
`VerseSelectionController` in `processFullBiblePassage`.
- `core/DisciplesJournalPlugin.ts` (`onload`) — construct `VerseSelectionService`
(`addChild`); `registerEvent(workspace.on('editor-menu', …))` for "Insert `<ref>` here";
add commands: *Insert selected verses at cursor*, *Copy selected verses*,
*Clear verse selection*; pass the service into the renderer.
- `settings/DisciplesJournalSettings.ts` — new settings (below); **reorganize the settings
tab into clearly headed sections** for navigation (e.g. *Display*, *Typography & colors*,
*Bible content & storage*, *ESV API*, *Verse selection*) using section headings
(`new Setting(containerEl).setHeading()`), grouping the existing settings under them.
- `components/BibleStyles.ts` — styles for `.dj-verse`, `.dj-verse-selected`, the action bar.
## Settings
New settings (grouped under a *Verse selection* heading in the reorganized tab):
- `enableVerseSelection: boolean` (default `true`).
- `defaultInsertFormat: 'inline' | 'codeblock' | 'blockquote'` (default `'inline'`); also
remembers the last-used format per session for the split-button body.
- `formatChooserStyle: 'split' | 'toggle' | 'submenu'` (default `'split'`) — which of the
three (all-implemented) format-chooser presentations the action bar uses.
- `enableAppendToNote: boolean` (default `false`) — surfaces the optional "Append to note…"
action on the bar / context menu / commands.
The settings tab itself is reorganized into headed sections (see touch points) so the
growing list stays navigable.
## Docs to update (per the repo's working agreements)
- **CLAUDE.md** — add the `## Design ethos` section (above).
- **ARCHITECTURE.md** — add the new modules to the module map + a short "verse selection"
data-flow note.
- **docs/reference-formats.md** — document comma-list (non-contiguous) references.
- **docs/gotchas.md** — the verse-wrapping approach, mobile long-press-vs-scroll handling,
per-Document action bar in popouts, selection lifecycle/clear-on-rerender.
- **docs/esv-api.md** — ESV attribution requirement note for reproduced verse text
(blockquote includes `(ESV)`; document the copyright-notice obligation).
- **CHANGELOG.md** — a bullet under `## Unreleased`.
## Extensibility
The action surfaces (bar, passage menu) are populated from a small in-memory list of
"verse-selection actions" `{ id, label, run(selection), enabled? }`. Copy / Insert (and the
optional Append-to-note) are the first registrations; the future "find notes referencing
these verses" is added by registering one more action — no UI rework.
## Risks / things to watch
- **Mobile long-press vs. scroll** — the highest-risk UX. Press threshold + cancel-on-move
before threshold. Fallback if janky: mobile uses tap-toggle only and relies on the bar.
- **Non-contiguous round-trip rendering** — the comma-list parser/resolve extension is the
largest code change; blockquote works without it, so it can land slightly later than the
text format.
- **Live Preview** — enable selection on rendered passages in reading mode and the
live-preview rendered widget; do not interfere with source editing.
- **Popout windows** — wrap/bar/styles are per-`Document` (follow the `BibleStyles` pattern).
- **ESV licensing** — honor attribution on reproduced text.
## Implementation phases
1. **Wrapping + selection state**: `VerseSelection` (+ tests), verse-id parsing & DOM
wrapping in the renderer, `VerseSelectionService`, highlight styles. Desktop tap-toggle
+ shift-range working; selection persists.
2. **Action surfaces**: `VerseActionBar` (all three format-chooser styles +
`formatChooserStyle` setting), passage right-click menu, editor `editor-menu`
"Insert here", and the three commands.
3. **Formats + locations**: `VerseFormatter` (+ tests) for all three formats; Copy
(clipboard) and Insert-at-cursor (last-focused editor). Optional `InsertTargetModal`
append behind `enableAppendToNote`.
4. **Non-contiguous round-trip**: extend parse/resolve for comma lists; reference-formats doc.
5. **Mobile gestures**: long-press-to-initiate + drag-to-extend with scroll guard.
6. **Settings (incl. tab reorganization into headed sections), docs, CHANGELOG, ethos
section**; build + lint gating.
## Verification
- `npm run build` (tsc + `npm test` + esbuild) and `npx eslint .` pass clean.
- Unit tests: `VerseSelection` run-collapsing (contiguous, non-contiguous, cross-chapter),
`VerseFormatter` output for all three formats, comma-list `BibleReference` parse round-trip.
- Manual in the demo vault (`disciples-journal-demo`):
- Open a note with a `bible` block (e.g. Genesis 1). Tap v2 and v5 → both highlight, bar
shows "Genesis 1:2, 5". Shift+click → contiguous range. ✕/Esc clears.
- Each action × each format → correct Copy (paste-check) and Insert-at-cursor output,
including the non-contiguous case. With `enableAppendToNote` on, the optional
"Append to note…" picker appends to the chosen note.
- Right-click in a second note → "Insert Genesis 1:2, 5 here" inserts at cursor.
- Inserted inline ref and code block render correctly (incl. non-contiguous).
- Mobile emulation: long-press initiates selection, drag extends, normal swipe scrolls.
- Popout window: selection + bar work in the popped-out doc.

View file

@ -6,6 +6,8 @@ import {BibleEventHandlers} from "src/core/BibleEventHandlers";
import {BibleFiles} from "../services/BibleFiles";
import {BibleReference} from "../core/BibleReference";
import {BiblePassage} from "../utils/BiblePassage";
import {VerseSelectionController} from "./VerseSelectionController";
import {VerseSelectionService} from "../core/VerseSelectionService";
/**
* Component for rendering Bible references in Obsidian
@ -15,15 +17,18 @@ export class BibleReferenceRenderer {
private bibleNavigation: BibleNavigation;
private plugin: DisciplesJournalPlugin;
private eventHandlers: BibleEventHandlers;
private selectionService: VerseSelectionService;
constructor(
bibleContentService: BibleContentService,
bibleFiles: BibleFiles,
plugin: DisciplesJournalPlugin
plugin: DisciplesJournalPlugin,
selectionService: VerseSelectionService
) {
this.bibleContentService = bibleContentService;
this.plugin = plugin;
this.bibleNavigation = new BibleNavigation(bibleFiles, plugin.app);
this.selectionService = selectionService;
}
/**
@ -88,10 +93,9 @@ export class BibleReferenceRenderer {
* Process full Bible passage code blocks
*/
public async processFullBiblePassage(source: string, el: HTMLElement): Promise<void> {
// Parse the reference
// Parse the reference (supports non-contiguous lists like "Genesis 1:2-3, 5")
const reference = source.trim();
const parsedRef = BibleReference.parse(reference);
if (!parsedRef) {
if (!BibleReference.parseList(reference) && !BibleReference.parse(reference)) {
const message = `Invalid bible reference: ${source}`;
console.error(message);
const errorContainer = el.createEl('div');
@ -100,8 +104,8 @@ export class BibleReferenceRenderer {
return;
}
// Grab the content
const response = await this.bibleContentService.getBibleContent(parsedRef);
// Grab the content (resolves and concatenates each contiguous run)
const response = await this.bibleContentService.getBibleContentList(reference);
if (response.isError()) {
const errorContainer = el.createEl('div');
@ -137,6 +141,14 @@ export class BibleReferenceRenderer {
containerEl.appendChild(passageEl);
el.appendChild(containerEl);
if (this.plugin.settings.enableVerseSelection) {
const controller = new VerseSelectionController(
this.plugin, passageEl, canonicalRef.book, this.selectionService
);
this.selectionService.addChild(controller); // unloads with the plugin/service
controller.load();
}
}
/**

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,165 @@
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(() => {
// Long-press engaged: select the pressed verse for immediate feedback, then
// subsequent moves extend the range (and stop the page scrolling).
this.dragging = true;
this.selection.add(hit.ref);
this.commit();
}, 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

@ -0,0 +1,41 @@
import { parseVerseId } from "../utils/VerseId";
/**
* Post-process a rendered ESV passage so each verse's inline text is wrapped in a
* `<span class="dj-verse" data-chapter data-verse>`, making verses selectable and
* highlightable. Idempotent: skips a passage that's already wrapped.
*
* ESV HTML marks verses only with `<b class="verse-num|chapter-num" id="vBBCCCVVV-N">`;
* a verse's text runs as loose nodes until the next marker, sometimes across <p>
* boundaries so we wrap per block element and tag spans with the verse number.
*/
export function wrapPassageVerses(passageEl: HTMLElement): void {
if (passageEl.querySelector(".dj-verse")) return;
const doc = passageEl.doc;
const blocks = passageEl.querySelectorAll("p, li");
blocks.forEach((block) => {
let current: HTMLSpanElement | null = null;
// Snapshot child nodes first; we re-parent them as we go.
const nodes = Array.from(block.childNodes);
for (const node of nodes) {
const marker =
node.instanceOf(HTMLElement) && (node.hasClass("verse-num") || node.hasClass("chapter-num"))
? parseVerseId(node.id)
: null;
if (marker) {
current = doc.createElement("span");
current.addClass("dj-verse");
current.dataset.chapter = String(marker.chapter);
current.dataset.verse = String(marker.verse);
block.insertBefore(current, node);
}
if (current) {
current.appendChild(node); // moves node out of block into the span
}
// Nodes before the first marker (rare) stay where they are.
}
});
}

View file

@ -116,6 +116,49 @@ export class BibleReference {
}
}
/**
* Parse a possibly non-contiguous list like "Genesis 1:2-3, 5" or "Genesis 1:31, 2:1".
* The first item is a full reference; later comma-separated items may be a bare verse
* ("5"), a bare verse range ("5-7") inheriting the prior book+chapter, or
* "chapter:verse[-end]" inheriting the book. Returns null if any item is invalid.
*/
public static parseList(reference: string): BibleReference[] | null {
const trimmed = reference.trim();
if (!trimmed) return null;
const items = trimmed.split(",").map((s) => s.trim().replace(/[–—]/g, "-"));
const out: BibleReference[] = [];
const first = BibleReference.parse(items[0]);
if (!first) return null;
out.push(first);
for (let i = 1; i < items.length; i++) {
const prev = out[out.length - 1];
const item = items[i];
// "chapter:verse" or "chapter:verse-end"
const cv = /^(\d+):(\d+)(?:-(\d+))?$/.exec(item);
if (cv) {
out.push(new BibleReference(prev.book, parseInt(cv[1], 10), parseInt(cv[2], 10),
cv[3] ? parseInt(cv[3], 10) : undefined));
continue;
}
// bare "verse" or "verse-end" (inherit prev chapter)
const v = /^(\d+)(?:-(\d+))?$/.exec(item);
if (v) {
out.push(new BibleReference(prev.book, prev.chapter, parseInt(v[1], 10),
v[2] ? parseInt(v[2], 10) : undefined));
continue;
}
return null;
}
return out;
}
/**
* Get the formatted reference as a string
*/

View file

@ -1,4 +1,4 @@
import {Plugin, MarkdownView, Notice, normalizePath} from 'obsidian';
import {Plugin, MarkdownView, Notice, normalizePath, Editor, Menu, MenuItem} from 'obsidian';
import {ESVApiService} from '../services/ESVApiService';
import {BibleContentService} from '../services/BibleContentService';
import {BibleReferenceRenderer} from '../components/BibleReferenceRenderer';
@ -15,6 +15,8 @@ import {BibleReference} from './BibleReference';
import {BibleEventHandlers} from './BibleEventHandlers';
import {applyCustomFrontmatter, getCustomFrontmatterForReference} from "../utils/FrontmatterUtil";
import {OpenBibleModal} from "../components/OpenBibleModal";
import {VerseSelectionService} from './VerseSelectionService';
import {buildPayload, runVerseAction} from '../components/VerseActions';
/**
* Disciples Journal Plugin for Obsidian
@ -33,6 +35,7 @@ export default class DisciplesJournalPlugin extends Plugin {
private bibleReferenceRenderer: BibleReferenceRenderer;
private bibleEventHandlers: BibleEventHandlers;
private bibleMarkupProcessor: BibleMarkupProcessor;
private verseSelectionService: VerseSelectionService;
async onload() {
// Initialize settings
@ -42,6 +45,10 @@ export default class DisciplesJournalPlugin extends Plugin {
this.esvApiService = new ESVApiService(this);
this.bibleContentService = new BibleContentService(this, this.esvApiService);
// Holds the single active verse selection across panes; unloads with the plugin.
this.verseSelectionService = new VerseSelectionService();
this.addChild(this.verseSelectionService);
// Check if ESV API token is set and show a notice if it's not
if (!this.settings.esvApiToken) {
new Notice('Disciples Journal: ESV API token not set. Bible content may not load correctly. Visit the plugin settings to add your API token.', 10000);
@ -54,7 +61,8 @@ export default class DisciplesJournalPlugin extends Plugin {
this.bibleReferenceRenderer = new BibleReferenceRenderer(
this.bibleContentService,
this.bibleFiles,
this
this,
this.verseSelectionService
);
// Single, long-lived hover-preview event handler owned by the plugin.
@ -93,6 +101,47 @@ export default class DisciplesJournalPlugin extends Plugin {
callback: () => this.updateAllBibleNoteFrontmatter()
});
this.addCommand({
id: 'insert-selected-verses',
name: 'Insert selected verses at cursor',
editorCallback: (editor: Editor) => {
const active = this.verseSelectionService.get();
if (!active) { new Notice('No verses selected.'); return; }
editor.replaceSelection(
buildPayload(this, active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl)
);
}
});
this.addCommand({
id: 'copy-selected-verses',
name: 'Copy selected verses',
callback: () => {
const active = this.verseSelectionService.get();
if (!active) { new Notice('No verses selected.'); return; }
void runVerseAction(this, 'copy', active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl);
}
});
this.addCommand({
id: 'clear-verse-selection',
name: 'Clear verse selection',
callback: () => this.verseSelectionService.clear()
});
// Right-click in any editor while a selection exists → insert at the click point.
this.registerEvent(this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor) => {
const active = this.verseSelectionService.get();
if (!active) return;
const label = active.selection.label();
menu.addItem((item: MenuItem) =>
item.setTitle(`Insert ${label} here`)
.setIcon('book-open')
.onClick(() => editor.replaceSelection(
buildPayload(this, active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl)
)));
}));
// Ribbon icon
this.addRibbonIcon('book-open', 'Open Bible', () => {
new OpenBibleModal(this.app, this.bibleFiles).open();

107
src/core/VerseSelection.ts Normal file
View file

@ -0,0 +1,107 @@
import { BibleReference } from "./BibleReference";
import { VerseRef } from "../utils/VerseId";
/**
* A mutable, ordered, de-duplicated set of verses within one book. Produces
* contiguous BibleReference runs and a human display label. Pure (no DOM / Obsidian).
*/
export class VerseSelection {
readonly book: string;
private verses: VerseRef[] = [];
constructor(book: string) {
this.book = book;
}
private indexOf(v: VerseRef): number {
return this.verses.findIndex((x) => x.chapter === v.chapter && x.verse === v.verse);
}
has(v: VerseRef): boolean {
return this.indexOf(v) !== -1;
}
isEmpty(): boolean {
return this.verses.length === 0;
}
count(): number {
return this.verses.length;
}
clear(): void {
this.verses = [];
}
add(v: VerseRef): void {
if (!this.has(v)) {
this.verses.push({ chapter: v.chapter, verse: v.verse });
this.sort();
}
}
toggle(v: VerseRef): void {
const i = this.indexOf(v);
if (i === -1) this.add(v);
else this.verses.splice(i, 1);
}
/** Add every verse from anchor..focus when they share a chapter; else just add focus. */
selectRange(anchor: VerseRef, focus: VerseRef): void {
if (anchor.chapter !== focus.chapter) {
this.add(focus);
return;
}
const lo = Math.min(anchor.verse, focus.verse);
const hi = Math.max(anchor.verse, focus.verse);
for (let v = lo; v <= hi; v++) this.add({ chapter: anchor.chapter, verse: v });
}
/** Sorted copy of the selected verses. */
verseList(): VerseRef[] {
return this.verses.map((v) => ({ chapter: v.chapter, verse: v.verse }));
}
private sort(): void {
this.verses.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse);
}
/** Collapse consecutive verses within a chapter into BibleReference ranges. */
runs(): BibleReference[] {
const out: BibleReference[] = [];
let start: VerseRef | null = null;
let prev: VerseRef | null = null;
const flush = () => {
if (!start || !prev) return;
const endVerse = prev.verse === start.verse ? undefined : prev.verse;
out.push(new BibleReference(this.book, start.chapter, start.verse, endVerse));
};
for (const v of this.verses) {
if (prev && v.chapter === prev.chapter && v.verse === prev.verse + 1) {
prev = v;
continue;
}
flush();
start = v;
prev = v;
}
flush();
return out;
}
/** YouVersion-style label, e.g. "Genesis 1:2-3, 5" or "Genesis 1:31, 2:1". */
label(): string {
const runs = this.runs();
if (runs.length === 0) return this.book;
let lastChapter: number | null = null;
const parts: string[] = [];
for (const r of runs) {
const versePart = r.endVerse !== undefined ? `${r.verse}-${r.endVerse}` : `${r.verse}`;
parts.push(r.chapter === lastChapter ? versePart : `${r.chapter}:${versePart}`);
lastChapter = r.chapter;
}
return `${this.book} ${parts.join(", ")}`;
}
}

View file

@ -0,0 +1,59 @@
import { Component } from "obsidian";
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 {
selection: VerseSelection;
owner: SelectionOwner;
}
/**
* Holds the single active verse selection across all panes/windows. Controllers
* subscribe; only the owning controller renders highlight + action bar.
*/
export class VerseSelectionService extends Component {
private active: ActiveSelection | null = null;
private listeners = new Set<() => void>();
/** Subscribe to selection changes. Returns an unsubscribe function. */
onChange(cb: () => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
get(): ActiveSelection | null {
return this.active;
}
/** Replace the active selection (or clear with an empty one). */
set(selection: VerseSelection, owner: SelectionOwner): void {
this.active = selection.isEmpty() ? null : { selection, owner };
this.emit();
}
clear(): void {
if (!this.active) return;
this.active = null;
this.emit();
}
/** Clear only if `owner` currently owns the selection (e.g. its passage re-rendered). */
clearIfOwner(owner: SelectionOwner): void {
if (this.active?.owner.id === owner.id) this.clear();
}
onunload(): void {
this.listeners.clear();
this.active = null;
}
private emit(): void {
for (const cb of this.listeners) cb();
}
}

View file

@ -69,6 +69,30 @@ export class BibleContentService {
}
}
/**
* Resolve a possibly non-contiguous reference string ("Genesis 1:2-3, 5") by parsing
* it into runs and resolving each. Returns one BiblePassage whose HTML is the runs'
* HTML concatenated and whose reference is the first run (used for the heading/link).
* Falls back to single-ref resolution when the string isn't a list.
*/
public async getBibleContentList(referenceText: string): Promise<BibleApiResponse> {
const runs = BibleReference.parseList(referenceText);
if (!runs || runs.length === 0) {
const single = BibleReference.parse(referenceText);
if (!single) return BibleApiResponse.error(`Invalid reference: ${referenceText}`, ErrorType.BadApiResponse);
return this.getBibleContent(single);
}
if (runs.length === 1) return this.getBibleContent(runs[0]);
let html = "";
for (const run of runs) {
const res = await this.getBibleContent(run);
if (res.isError()) return res;
html += res.passage.html;
}
return BibleApiResponse.success(new BiblePassage(runs[0], html));
}
private getCachedRef(ref: BibleReference): BiblePassage | undefined {
if (this.passageCache.has(ref)) {
return this.passageCache.get(ref);

View file

@ -22,6 +22,10 @@ export interface DisciplesJournalSettings {
hideFootnotesInPreview: boolean;
chapterNoteFrontmatter: string;
passageNoteFrontmatter: string;
enableVerseSelection: boolean;
defaultInsertFormat: 'inline' | 'codeblock' | 'blockquote';
formatChooserStyle: 'split' | 'toggle' | 'submenu';
enableAppendToNote: boolean;
}
export const DEFAULT_SETTINGS: DisciplesJournalSettings = {
@ -41,7 +45,11 @@ export const DEFAULT_SETTINGS: DisciplesJournalSettings = {
hideFootnotes: false,
hideFootnotesInPreview: false,
chapterNoteFrontmatter: '',
passageNoteFrontmatter: ''
passageNoteFrontmatter: '',
enableVerseSelection: true,
defaultInsertFormat: 'inline',
formatChooserStyle: 'split',
enableAppendToNote: false
};
export class DisciplesJournalSettingsTab extends PluginSettingTab {
@ -266,6 +274,54 @@ export class DisciplesJournalSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
new Setting(containerEl).setName('Verse selection').setHeading();
new Setting(containerEl)
.setName('Enable verse selection')
.setDesc('Tap verses in a rendered passage to select them and copy/insert them into notes.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableVerseSelection)
.onChange(async (value) => {
this.plugin.settings.enableVerseSelection = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default insert format')
.setDesc('The format used by the main button before you pick another.')
.addDropdown(dropdown => dropdown
.addOption('inline', 'Inline reference')
.addOption('codeblock', 'Bible code block')
.addOption('blockquote', 'Blockquote with text')
.setValue(this.plugin.settings.defaultInsertFormat)
.onChange(async (value) => {
this.plugin.settings.defaultInsertFormat = value as DisciplesJournalSettings['defaultInsertFormat'];
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Format chooser style')
.setDesc('How the action bar lets you pick a format.')
.addDropdown(dropdown => dropdown
.addOption('split', 'Split buttons (default format + chevron)')
.addOption('toggle', 'Format toggle + action buttons')
.addOption('submenu', 'Action menus with format submenus')
.setValue(this.plugin.settings.formatChooserStyle)
.onChange(async (value) => {
this.plugin.settings.formatChooserStyle = value as DisciplesJournalSettings['formatChooserStyle'];
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable "Append to note…"')
.setDesc('Add an action that appends the selection to the end of a note you pick. Overlaps with Insert at cursor, so it is off by default.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableAppendToNote)
.onChange(async (value) => {
this.plugin.settings.enableAppendToNote = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl).setName('ESV API').setHeading();
const apiInfoDiv = containerEl.createDiv({cls: 'disciples-journal-api-info'});

View file

@ -0,0 +1,21 @@
/** `` `Genesis 1:2-3, 5` `` — inline reference rendered by the plugin's hover preview. */
export function formatInlineReference(label: string): string {
return `\`${label}\``;
}
/** A fenced ```bible block the plugin renders as the full passage. */
export function formatCodeBlock(label: string): string {
return "```bible\n" + label + "\n```";
}
/**
* A markdown blockquote of the actual verse text, ending in a "— <ref> (<version>)"
* citation. Every line of `text` is prefixed so multi-line quotes stay inside the quote.
*/
export function formatBlockquote(label: string, text: string, version: string): string {
const body = text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
return `${body}\n>\n> — ${label} (${version})`;
}

18
src/utils/VerseId.ts Normal file
View file

@ -0,0 +1,18 @@
/** A chapter+verse coordinate within a single book. */
export interface VerseRef {
chapter: number;
verse: number;
}
const VERSE_ID = /^v\d{2}(\d{3})(\d{3})-\d+$/;
/**
* Parse an ESV passage marker id (e.g. "v01001002-1" on a `verse-num`/`chapter-num`
* `<b>`) into its chapter and verse. Returns null for ids that aren't verse markers.
* Book digits are ignored callers know the book from the passage reference.
*/
export function parseVerseId(id: string): VerseRef | null {
const m = VERSE_ID.exec(id);
if (!m) return null;
return { chapter: parseInt(m[1], 10), verse: parseInt(m[2], 10) };
}

View file

@ -527,3 +527,90 @@ button.clear-input-button {
.disciples-journal-book-suggest-input {
padding-right: 1.5em;
}
/* ===== Verse selection ===== */
.dj-selectable .dj-verse {
cursor: pointer;
border-radius: var(--radius-s);
}
.dj-selectable .dj-verse:hover {
background: var(--background-modifier-hover);
}
.dj-verse-selected,
.dj-selectable .dj-verse-selected:hover {
background: var(--text-selection);
box-shadow: inset 0 0 0 1px var(--text-accent);
}
/* Floating action bar (appears only while verses are selected) */
.dj-verse-action-bar {
position: fixed;
z-index: var(--layer-popover);
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-2) var(--size-4-3);
border-radius: var(--radius-m);
background: var(--background-secondary);
box-shadow: var(--shadow-l);
}
.dj-verse-action-label {
font-weight: var(--font-bold);
margin-right: var(--size-4-1);
}
.dj-verse-action-bar button {
cursor: pointer;
}
.dj-split-button {
display: inline-flex;
}
.dj-split-button .dj-split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dj-split-button .dj-split-chevron {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: var(--size-4-1);
padding-right: var(--size-4-1);
}
.dj-format-toggle {
display: inline-flex;
gap: var(--size-2-1);
margin-right: var(--size-4-1);
}
.dj-format-toggle button.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.dj-verse-action-bar button:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
/* Bottom-dock and enlarge touch targets on narrow / mobile layouts */
@media (max-width: 600px) {
.dj-verse-action-bar {
left: var(--size-4-2) !important;
right: var(--size-4-2);
top: auto !important;
bottom: var(--size-4-3);
justify-content: center;
flex-wrap: wrap;
}
.dj-verse-action-bar button {
min-height: 44px;
min-width: 44px;
}
}

View file

@ -179,3 +179,35 @@ test("value-object helpers", async (t) => {
test("constructor throws on an unnormalizable book name", () => {
assert.throws(() => new BibleReference("Xylophone", 1), /Illegal book name/);
});
test("parseList — comma/non-contiguous lists", async (t) => {
await t.test("single ref still parses as a one-element list", () => {
const list = BibleReference.parseList("John 3:16");
assert.ok(list);
assert.equal(list.length, 1);
assert.equal(list[0].toString(), "John 3:16");
});
await t.test("bare verse inherits book + chapter", () => {
const list = BibleReference.parseList("Genesis 1:2-3, 5");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:2-3", "Genesis 1:5"]);
});
await t.test("chapter:verse item inherits only the book", () => {
const list = BibleReference.parseList("Genesis 1:31, 2:1");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:31", "Genesis 2:1"]);
});
await t.test("round-trips a VerseSelection label", () => {
const list = BibleReference.parseList("Genesis 1:2-4, 7, 2:1");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:2-4", "Genesis 1:7", "Genesis 2:1"]);
});
await t.test("invalid item makes the whole list null", () => {
assert.equal(BibleReference.parseList("Genesis 1:2, banana"), null);
assert.equal(BibleReference.parseList(""), null);
});
});

View file

@ -0,0 +1,23 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatInlineReference, formatCodeBlock, formatBlockquote } from "../src/utils/VerseFormatter";
test("VerseFormatter", async (t) => {
await t.test("inline reference is backtick-wrapped", () => {
assert.equal(formatInlineReference("Genesis 1:2-3, 5"), "`Genesis 1:2-3, 5`");
});
await t.test("code block is a fenced bible block", () => {
assert.equal(formatCodeBlock("Genesis 1:2-3"), "```bible\nGenesis 1:2-3\n```");
});
await t.test("blockquote quotes the text and cites the reference + version", () => {
const out = formatBlockquote("John 3:16", "For God so loved the world...", "ESV");
assert.equal(out, "> For God so loved the world...\n>\n> — John 3:16 (ESV)");
});
await t.test("blockquote prefixes every wrapped line with '> '", () => {
const out = formatBlockquote("Genesis 1:1", "line one\nline two", "ESV");
assert.equal(out, "> line one\n> line two\n>\n> — Genesis 1:1 (ESV)");
});
});

20
test/VerseId.test.ts Normal file
View file

@ -0,0 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseVerseId } from "../src/utils/VerseId";
test("parseVerseId", async (t) => {
await t.test("verse-num id", () => {
assert.deepEqual(parseVerseId("v01001002-1"), { chapter: 1, verse: 2 });
});
await t.test("chapter-num id (first verse)", () => {
assert.deepEqual(parseVerseId("v01001001-1"), { chapter: 1, verse: 1 });
});
await t.test("multi-digit chapter and verse", () => {
assert.deepEqual(parseVerseId("v43011035-2"), { chapter: 11, verse: 35 });
});
await t.test("non-matching ids return null", () => {
assert.equal(parseVerseId("f1-1"), null);
assert.equal(parseVerseId(""), null);
assert.equal(parseVerseId("v123-1"), null);
});
});

View file

@ -0,0 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { VerseSelection } from "../src/core/VerseSelection";
function sel(book: string, ...vs: [number, number][]): VerseSelection {
const s = new VerseSelection(book);
for (const [c, v] of vs) s.toggle({ chapter: c, verse: v });
return s;
}
test("VerseSelection", async (t) => {
await t.test("toggle adds then removes", () => {
const s = sel("Genesis");
s.toggle({ chapter: 1, verse: 2 });
assert.equal(s.has({ chapter: 1, verse: 2 }), true);
s.toggle({ chapter: 1, verse: 2 });
assert.equal(s.has({ chapter: 1, verse: 2 }), false);
assert.equal(s.isEmpty(), true);
});
await t.test("contiguous run collapses to a range label", () => {
const s = sel("Genesis", [1, 2], [1, 3], [1, 4]);
assert.equal(s.label(), "Genesis 1:2-4");
const runs = s.runs();
assert.equal(runs.length, 1);
assert.equal(runs[0].toString(), "Genesis 1:2-4");
});
await t.test("non-contiguous verses join with commas (out-of-order input)", () => {
const s = sel("Genesis", [1, 5], [1, 2], [1, 3]);
assert.equal(s.label(), "Genesis 1:2-3, 5");
assert.deepEqual(s.runs().map((r) => r.toString()), ["Genesis 1:2-3", "Genesis 1:5"]);
});
await t.test("single verse", () => {
assert.equal(sel("John", [3, 16]).label(), "John 3:16");
});
await t.test("multiple chapters repeat the chapter, no cross-chapter merge", () => {
const s = sel("Genesis", [1, 31], [2, 1]);
assert.equal(s.label(), "Genesis 1:31, 2:1");
assert.equal(s.runs().length, 2);
});
await t.test("selectRange fills verses within a chapter", () => {
const s = new VerseSelection("Genesis");
s.selectRange({ chapter: 1, verse: 2 }, { chapter: 1, verse: 5 });
assert.equal(s.label(), "Genesis 1:2-5");
});
await t.test("verseList is sorted", () => {
const s = sel("Genesis", [1, 5], [1, 2]);
assert.deepEqual(s.verseList(), [
{ chapter: 1, verse: 2 },
{ chapter: 1, verse: 5 },
]);
});
});