Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab416930b | ||
|
|
aa27a5bb18 | ||
|
|
7101be393e | ||
|
|
820886e3ab | ||
|
|
d273c8cb3e |
No files matched your search
@@ -224,6 +224,77 @@ describe('Outline stage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline: per chunk, details, print and share', () => {
|
||||
async function openOutline() {
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||
const user = await openOutline();
|
||||
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||
});
|
||||
|
||||
test('a point can be given a type and minutes, and the total shows', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Passage'));
|
||||
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||
});
|
||||
|
||||
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||
const doc = { write: vi.fn(), close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Study cards on Home', () => {
|
||||
test('a long run of chapters and many tags stay short', async () => {
|
||||
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||
|
||||
@@ -14,6 +14,7 @@ const BY_PAGE = {
|
||||
['/', 'Search this chapter or the whole Bible'],
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
|
||||
// words that differ are highlighted. The chapter shows how different the two are.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
|
||||
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
|
||||
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Compare translations">
|
||||
<p className="mb-3 text-sm text-[var(--r-sub)]">
|
||||
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
|
||||
</p>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
|
||||
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
|
||||
{options.map((t) => (
|
||||
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
|
||||
))}
|
||||
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
|
||||
</div>
|
||||
{parallel && (
|
||||
<>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
|
||||
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
|
||||
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
|
||||
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Text with the words that differ from the other translation marked.
|
||||
export function DiffText({ text, ranges = [] }) {
|
||||
if (!ranges.length) return text;
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((r, i) => {
|
||||
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
|
||||
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
|
||||
cursor = r.end;
|
||||
});
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||
// mode hides both.
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
|
||||
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
||||
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||
return (
|
||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// The page itself: chapter heading, translation notice, and verses set in a
|
||||
// serif face. Tap a verse to open the verse sheet; select text to highlight.
|
||||
import { useMemo } from 'react';
|
||||
import { formatCrossRef } from '../../utils/wordStudy.js';
|
||||
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
|
||||
import { DiffText } from './DiffText.jsx';
|
||||
|
||||
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
|
||||
const FONT_STYLES = {
|
||||
@@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
|
||||
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
|
||||
export default function ReaderText({
|
||||
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||
}) {
|
||||
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
|
||||
const diff = useMemo(
|
||||
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
|
||||
[parallel, showDiff, verses],
|
||||
);
|
||||
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
|
||||
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
|
||||
const handleTap = (n) => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
|
||||
@@ -92,6 +103,23 @@ export default function ReaderText({
|
||||
|
||||
{!loading && !error && sideBySide && !parallel.error && (
|
||||
<div style={bodyStyle} className="font-serif">
|
||||
{diff && (
|
||||
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
|
||||
<p>
|
||||
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
|
||||
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
|
||||
</p>
|
||||
{diff.mostDifferent.length > 0 && (
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||
Most different:{' '}
|
||||
{diff.mostDifferent.map((v, i) => (
|
||||
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||
</div>
|
||||
@@ -99,12 +127,14 @@ export default function ReaderText({
|
||||
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||
<p>
|
||||
{row.first
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
|
||||
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||
</p>
|
||||
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
|
||||
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
|
||||
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import ReaderText from './ReaderText.jsx';
|
||||
|
||||
const verses = [
|
||||
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||
{ number: 2, text: 'Now the earth was formless and void.' },
|
||||
];
|
||||
const second = [
|
||||
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||
{ number: 2, text: 'Now the earth was without shape and empty.' },
|
||||
];
|
||||
const base = {
|
||||
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
|
||||
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
|
||||
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
|
||||
};
|
||||
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
|
||||
|
||||
test('side by side, it says how different the other Bible is and marks the differing words', () => {
|
||||
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
|
||||
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
|
||||
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
|
||||
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
|
||||
expect(marked).toContain('without shape'); // the other Bible's side
|
||||
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
|
||||
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
|
||||
});
|
||||
|
||||
test('identical wording is reported as the same words', () => {
|
||||
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
|
||||
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with highlighting off there is no summary and no marks', () => {
|
||||
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
|
||||
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
|
||||
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
|
||||
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the left Bible gets its own differing words passed to the verse renderer', () => {
|
||||
const renderVerseText = vi.fn((_n, text) => text);
|
||||
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
|
||||
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
|
||||
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
|
||||
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
// to read beside the first.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_THEMES } from './readerThemes.js';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
|
||||
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||
|
||||
@@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) {
|
||||
export default function SettingsSheet({
|
||||
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||
translation, parallel, onParallel,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||
@@ -56,18 +54,6 @@ export default function SettingsSheet({
|
||||
<span className={label}>Width</span>
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Read beside another Bible</span>
|
||||
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
|
||||
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
|
||||
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.id || 'Off'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
||||
import { DiffText } from './DiffText.jsx';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
@@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
return () => controller.abort();
|
||||
}, [book, chapter, verse]);
|
||||
|
||||
const mine = rows[current]?.text;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{READER_TRANSLATIONS.map((t) => {
|
||||
const row = rows[t.id];
|
||||
// How this wording differs from the Bible being read, word by word
|
||||
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
|
||||
return (
|
||||
<div key={t.id}>
|
||||
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
|
||||
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
|
||||
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
|
||||
</p>
|
||||
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
|
||||
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
|
||||
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
|
||||
Copyright notices for each translation appear at the foot of the chapter when you read it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4
|
||||
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
|
||||
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
|
||||
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
|
||||
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
|
||||
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
|
||||
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
|
||||
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
|
||||
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
|
||||
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
|
||||
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
|
||||
const [startPagePref, setStartPagePrefState] = useState(() => {
|
||||
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
|
||||
planTranslationPref, setPlanTranslationPref,
|
||||
startPagePref, setStartPagePref,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
|
||||
@@ -82,3 +82,10 @@ select {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Words that differ between two translations (Reader → read beside another Bible) */
|
||||
.diff-hl {
|
||||
background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent);
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
+195
-65
@@ -1,21 +1,28 @@
|
||||
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
|
||||
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
|
||||
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
|
||||
// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at
|
||||
// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's
|
||||
// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file.
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
import { buildChapterSummary } from '../utils/projectStorage.js';
|
||||
import { compactPassages } from '../utils/passageSummary.js';
|
||||
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
||||
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
|
||||
import {
|
||||
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
|
||||
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
|
||||
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
||||
} from '../utils/outline.js';
|
||||
|
||||
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
const field = 'mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]';
|
||||
const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]';
|
||||
|
||||
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
|
||||
function PointText({ value, onChange, ...rest }) {
|
||||
function AutoText({ value, onChange, className, ...rest }) {
|
||||
const ref = useRef(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -23,23 +30,25 @@ function PointText({ value, onChange, ...rest }) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
return (
|
||||
<textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className="block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none"
|
||||
{...rest} />
|
||||
);
|
||||
return <textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)} className={className} {...rest} />;
|
||||
}
|
||||
const pointBox = 'block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none';
|
||||
|
||||
export default function OutlinePage() {
|
||||
const { project, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const { project, updateProject, setCurrentPage } = useApp();
|
||||
const { toast } = useToast();
|
||||
const tree = project?.outline ?? [];
|
||||
const [chosen, setChosen] = useState(STUDY);
|
||||
const scope = chosen === STUDY || chunkOf(project, chosen) ? chosen : STUDY; // a deleted chunk falls back to the whole study
|
||||
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
|
||||
const tree = outlineFor(project, scope);
|
||||
const rows = flatten(tree);
|
||||
const [pendingFocus, setPendingFocus] = useState(null);
|
||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose passage/chunk fields are showing
|
||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
|
||||
|
||||
// The one way every change is made, always from the latest outline (typing is fast).
|
||||
const edit = (fn) => updateProject((current) => ({ ...current, outline: fn(current.outline ?? []) }));
|
||||
const edit = (fn) => updateProject((current) => withOutline(current, scope, fn(outlineFor(current, scope))));
|
||||
const patch = (id, change) => edit((t) => updateNode(t, id, change));
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFocus) return;
|
||||
@@ -47,27 +56,27 @@ export default function OutlinePage() {
|
||||
setPendingFocus(null);
|
||||
}, [pendingFocus, tree]);
|
||||
|
||||
// The chunk choices, named by passage
|
||||
// Every chunk, named by passage, for the scope picker and the "linked chunk" choice
|
||||
const chunkChoices = [];
|
||||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
|
||||
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–') });
|
||||
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
|
||||
}));
|
||||
const unlinked = seedFromChunks(project, tree).length - tree.length;
|
||||
const scopeLabel = scope === STUDY ? 'Whole study' : chunkChoices.find((c) => c.id === scope)?.label ?? '';
|
||||
const title = scope === STUDY ? project.title : `${project.title} — ${scopeLabel}`;
|
||||
const subtitle = scope === STUDY ? compactPassages(buildChapterSummary(project)) : `${scopeLabel} · ${project.translation ?? 'BSB'}`;
|
||||
const outline = { title, subtitle, tree };
|
||||
|
||||
const addMain = () => {
|
||||
const node = newNode();
|
||||
edit((t) => addRoot(t, node));
|
||||
setPendingFocus(node.id);
|
||||
};
|
||||
const addSub = (id) => {
|
||||
const node = newNode();
|
||||
edit((t) => addChild(t, id, node));
|
||||
setPendingFocus(node.id);
|
||||
};
|
||||
const addSibling = (id) => {
|
||||
const node = newNode();
|
||||
edit((t) => addAfter(t, id, node));
|
||||
setPendingFocus(node.id);
|
||||
const addable = scope === STUDY
|
||||
? seedFromChunks(project, tree).length - tree.length
|
||||
: seedFromVerses(project, scope, tree).length - tree.length;
|
||||
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
|
||||
|
||||
const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); };
|
||||
const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); };
|
||||
const addSibling = (id) => { const node = newNode(); edit((t) => addAfter(t, id, node)); setPendingFocus(node.id); };
|
||||
const seed = () => {
|
||||
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
|
||||
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
|
||||
};
|
||||
const remove = (id) => {
|
||||
const where = locate(tree, id);
|
||||
@@ -94,42 +103,87 @@ export default function OutlinePage() {
|
||||
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
|
||||
};
|
||||
|
||||
// ---- taking it out: copy, share, print, files
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
|
||||
};
|
||||
const addChunks = () => {
|
||||
edit((t) => seedFromChunks(project, t));
|
||||
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
|
||||
const share = async () => {
|
||||
const result = await shareOutline(outline);
|
||||
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||
else if (result === 'failed') toast("Couldn't share or copy the outline.", { tone: 'error' });
|
||||
};
|
||||
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
|
||||
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
|
||||
|
||||
const openChunk = (chunkId) => {
|
||||
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
|
||||
setCurrentPage('study');
|
||||
};
|
||||
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||
|
||||
// Puts a passage's words into a point (the point's passage field says which).
|
||||
const addVerseText = async (node) => {
|
||||
setLookup({ id: node.id, busy: true, error: '' });
|
||||
try {
|
||||
const got = await fetchPassageText(node.ref, project.translation ?? 'BSB');
|
||||
patch(node.id, { scripture: got });
|
||||
setLookup({ id: null, busy: false, error: '' });
|
||||
} catch (err) {
|
||||
setLookup({ id: node.id, busy: false, error: err.message || 'Could not load those verses.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StudioShell stage="outline">
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-medium">Outline</h1>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message. Keep one for the whole study, and one for each chunk if you like.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--r-sub)]">Outline for
|
||||
<select value={scope} onChange={(e) => setChosen(e.target.value)} aria-label="Outline for"
|
||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value={STUDY}>Whole study{countNodes(project.outline ?? []) ? ` · ${countNodes(project.outline)}` : ''}</option>
|
||||
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}{c.count ? ` · ${c.count}` : ''}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
|
||||
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
|
||||
<button type="button" onClick={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
|
||||
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
|
||||
<button type="button" onClick={seed} disabled={addable <= 0} className={btn}
|
||||
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
|
||||
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
|
||||
</button>
|
||||
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
|
||||
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
|
||||
<button type="button" onClick={share} disabled={tree.length === 0} className={btn}>Share</button>
|
||||
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
|
||||
<details className="relative">
|
||||
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
|
||||
<div className="absolute left-0 z-20 mt-1 w-48 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-1 shadow-lg">
|
||||
<button type="button" onClick={copy} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Copy as text</button>
|
||||
<button type="button" onClick={word} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Word (.docx)</button>
|
||||
<button type="button" onClick={() => downloadOutlineMarkdown(outline)} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Markdown</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
|
||||
<p className="text-base font-medium">No outline yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
|
||||
<p className="text-base font-medium">No outline yet{scope === STUDY ? '' : ` for ${scopeLabel}`}</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">
|
||||
{scope === STUDY
|
||||
? 'Start from your chunks to get a main point for each passage you studied, or start blank and write your own.'
|
||||
: 'Start from the verses to get a point for each one, with its words, or start blank and write your own.'}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
<button type="button" onClick={addChunks} disabled={unlinked === 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Start from my chunks</button>
|
||||
<button type="button" onClick={seed} disabled={addable <= 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
|
||||
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
|
||||
</button>
|
||||
<button type="button" onClick={addMain} className={btn}>Start blank</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,54 +191,130 @@ export default function OutlinePage() {
|
||||
<ul aria-label="Outline" className="space-y-0.5">
|
||||
{rows.map(({ node, depth, label }, at) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const showMeta = metaOpen.has(node.id) || (!!node.ref && false);
|
||||
const showMeta = metaOpen.has(node.id);
|
||||
const linked = chunkChoices.find((c) => c.id === node.chunkId);
|
||||
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
|
||||
const place = siblings.findIndex((r) => r.node.id === node.id);
|
||||
const attachSource = chunkOf(project, node.chunkId ?? scopeChunk?.id);
|
||||
const attached = new Set((node.attachments ?? []).map((a) => a.id));
|
||||
const available = attachableFromChunk(attachSource).filter((a) => !attached.has(a.key));
|
||||
const mins = Number.isFinite(node.minutes) && node.minutes > 0;
|
||||
const noteLine = (node.note ?? '').trim().split('\n')[0];
|
||||
return (
|
||||
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PointText
|
||||
value={node.text} data-outline-id={node.id}
|
||||
aria-label={`Point ${label}`}
|
||||
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
|
||||
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
|
||||
<AutoText
|
||||
value={node.text} data-outline-id={node.id} className={pointBox}
|
||||
aria-label={`Point ${label}`} placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
|
||||
onChange={(value) => patch(node.id, { text: value })}
|
||||
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
|
||||
/>
|
||||
{(node.ref || linked) && !showMeta && (
|
||||
<p className="px-2 text-xs text-[var(--r-sub)]">
|
||||
{node.ref}
|
||||
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
|
||||
|
||||
{/* What the point carries, at a glance */}
|
||||
{(node.kind || mins || node.ref || linked) && (
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
|
||||
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
|
||||
{mins && <span>{node.minutes} min</span>}
|
||||
{node.ref && <span>{node.ref}</span>}
|
||||
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
|
||||
</p>
|
||||
)}
|
||||
{noteLine && !showMeta && <p className="truncate px-2 text-xs italic text-[var(--r-sub)]">{noteLine}</p>}
|
||||
{node.scripture?.text && !showMeta && (
|
||||
<blockquote className="mx-2 mt-1 border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text.length > 220 ? `${node.scripture.text.slice(0, 220)}…` : node.scripture.text}” <span className="not-italic">— {node.scripture.ref}</span></blockquote>
|
||||
)}
|
||||
{(node.attachments ?? []).length > 0 && !showMeta && (
|
||||
<div className="mt-1 flex flex-wrap gap-1 px-2">
|
||||
{node.attachments.map((a) => <span key={a.id} className={chip} title={a.detail}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMeta && (
|
||||
<div className="mb-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
|
||||
<label className="text-xs text-[var(--r-sub)]">Passage
|
||||
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
|
||||
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]" />
|
||||
<div className="mb-2 mt-1 space-y-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_6rem]">
|
||||
<label className="text-xs text-[var(--r-sub)]">Type
|
||||
<select value={node.kind ?? ''} onChange={(e) => patch(node.id, { kind: e.target.value })} className={field}>
|
||||
{KINDS.map(([id, text]) => <option key={id} value={id}>{text}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Minutes
|
||||
<input type="number" min="0" max="240" inputMode="numeric" value={node.minutes ?? ''}
|
||||
onChange={(e) => patch(node.id, { minutes: e.target.value === '' ? null : Math.max(0, Math.min(240, Math.round(Number(e.target.value)))) })} className={field} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs text-[var(--r-sub)]">Notes
|
||||
<AutoText value={node.note ?? ''} onChange={(value) => patch(node.id, { note: value })} placeholder="Anything to remember when you speak or write this point"
|
||||
className={`${field} min-h-[4.5rem] resize-none overflow-hidden`} aria-label={`Notes for ${label}`} />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--r-sub)]">Passage
|
||||
<input value={node.ref ?? ''} onChange={(e) => patch(node.id, { ref: e.target.value })} placeholder="e.g. Titus 1:5–9" className={field} />
|
||||
</label>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button type="button" disabled={!node.ref?.trim() || (lookup.busy && lookup.id === node.id)} onClick={() => addVerseText(node)} className={`${btn} !px-3 !py-1 text-xs`}>
|
||||
{lookup.busy && lookup.id === node.id ? 'Loading…' : node.scripture ? 'Replace verse text' : 'Add verse text'}
|
||||
</button>
|
||||
{node.scripture && <button type="button" onClick={() => patch(node.id, { scripture: null })} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove</button>}
|
||||
</div>
|
||||
{lookup.id === node.id && lookup.error && <p role="alert" className="mt-1 text-xs text-[var(--r-danger,#b42318)]">{lookup.error}</p>}
|
||||
</div>
|
||||
<label className="text-xs text-[var(--r-sub)]">Linked chunk
|
||||
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
|
||||
<select value={node.chunkId ?? ''} onChange={(e) => patch(node.id, {
|
||||
chunkId: e.target.value || null,
|
||||
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
|
||||
}))}
|
||||
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||
})} className={field}>
|
||||
<option value="">None</option>
|
||||
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{node.scripture?.text && (
|
||||
<blockquote className="border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text}” <span className="not-italic">— {node.scripture.ref} ({node.scripture.translation})</span></blockquote>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-[var(--r-sub)]">Attach from {attachSource ? 'its chunk' : 'a chunk'}</p>
|
||||
{(node.attachments ?? []).length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{node.attachments.map((a) => (
|
||||
<span key={a.id} className={`${chip} inline-flex items-center gap-1`} title={a.detail}>
|
||||
{a.type === 'word' ? 'Word' : 'See'}: {a.label}
|
||||
<button type="button" aria-label={`Remove ${a.label}`} onClick={() => patch(node.id, { attachments: node.attachments.filter((x) => x.id !== a.id) })} className="hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!attachSource ? (
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Link a chunk above to attach its cross-references and word studies.</p>
|
||||
) : available.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{(node.attachments ?? []).length ? 'Everything from this chunk is attached.' : 'This chunk has no cross-references or word studies yet. Add some in the Study stage.'}</p>
|
||||
) : (
|
||||
<select value="" aria-label={`Attach to ${label}`} className={field}
|
||||
onChange={(e) => {
|
||||
const pick = available.find((a) => a.key === e.target.value);
|
||||
if (pick) patch(node.id, { attachments: [...(node.attachments ?? []), { id: pick.key, type: pick.type, label: pick.label, detail: pick.detail }] });
|
||||
}}>
|
||||
<option value="">Choose a cross-reference or word…</option>
|
||||
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => { edit((t) => outdent(t, node.id)); }}>←</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => { edit((t) => indent(t, node.id)); }}>→</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => edit((t) => outdent(t, node.id))}>←</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => edit((t) => indent(t, node.id))}>→</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(node.id)}>⋯</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Details for ${label}`} aria-expanded={showMeta} title="Type, minutes, notes, verse text, attachments" onClick={() => toggleMeta(node.id)}>⋯</button>
|
||||
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +326,7 @@ export default function OutlinePage() {
|
||||
|
||||
{tree.length > 0 && (
|
||||
<p className="mt-6 text-xs text-[var(--r-sub)]">
|
||||
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it.
|
||||
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it · ⋯ opens its details.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -15,6 +15,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
|
||||
import PlanStrip from '../components/reader/PlanStrip.jsx';
|
||||
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
@@ -47,6 +48,7 @@ export default function ReaderPage() {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers, setReaderVerseNumbers,
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff, setReaderShowDiff,
|
||||
readerBookmarks,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
@@ -271,6 +273,7 @@ export default function ReaderPage() {
|
||||
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
|
||||
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
|
||||
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
|
||||
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
|
||||
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
|
||||
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
|
||||
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
|
||||
@@ -302,8 +305,9 @@ export default function ReaderPage() {
|
||||
|
||||
// Render verse text with colored highlights and optional search match highlight
|
||||
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||
function renderVerseText(verseNum, text, searchLow) {
|
||||
function renderVerseText(verseNum, text, searchLow, diffRanges) {
|
||||
const spans = [];
|
||||
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
|
||||
for (const h of (readerTextHighlights || [])) {
|
||||
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
|
||||
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
|
||||
@@ -318,6 +322,11 @@ export default function ReaderPage() {
|
||||
}
|
||||
}
|
||||
if (!spans.length) return text;
|
||||
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
|
||||
const firm = spans.filter((x) => x.type !== 'diff');
|
||||
for (let i = spans.length - 1; i >= 0; i -= 1) {
|
||||
if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1);
|
||||
}
|
||||
spans.sort((a, b) => a.start - b.start);
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
@@ -332,6 +341,8 @@ export default function ReaderPage() {
|
||||
{text.slice(s.start, s.end)}
|
||||
</mark>,
|
||||
);
|
||||
} else if (s.type === 'diff') {
|
||||
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
|
||||
} else {
|
||||
parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>);
|
||||
}
|
||||
@@ -396,6 +407,7 @@ export default function ReaderPage() {
|
||||
onSearch={() => openSheet('search')}
|
||||
onBookmarks={() => openSheet('bookmarks')}
|
||||
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
|
||||
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
|
||||
onSettings={() => openSheet('settings')}
|
||||
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
|
||||
/>
|
||||
@@ -429,7 +441,7 @@ export default function ReaderPage() {
|
||||
title={`${readerBook?.name ?? ''} ${readerChapter}`}
|
||||
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
|
||||
translationId={readerTranslation}
|
||||
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
|
||||
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff}
|
||||
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
|
||||
notice={readerNotice}
|
||||
verses={readerVerses}
|
||||
@@ -549,7 +561,11 @@ export default function ReaderPage() {
|
||||
font={readerFont} onFont={setReaderFont}
|
||||
spacing={readerSpacing} onSpacing={setReaderSpacing}
|
||||
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
|
||||
/>
|
||||
<CompareSheet
|
||||
open={sheet === 'compare'} onClose={closeSheet}
|
||||
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
|
||||
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
|
||||
/>
|
||||
<SearchSheet
|
||||
open={sheet === 'search'} onClose={closeSheet}
|
||||
|
||||
@@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
|
||||
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
|
||||
ctx.setReaderParallel = vi.fn();
|
||||
render(<ReaderPage />);
|
||||
const compare = screen.getByLabelText('Compare translations');
|
||||
expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
|
||||
fireEvent.click(compare);
|
||||
expect(screen.getByText(/how different its wording is/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'NET' }));
|
||||
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
|
||||
});
|
||||
|
||||
test('tapping a verse selects it', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { labelFor as outlineLabel } from './outlineLabels.js';
|
||||
import { labelFor as outlineLabel, kindLabel as outlineKindLabel } from './outlineLabels.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
@@ -193,6 +193,10 @@ export function buildExportHtml(project) {
|
||||
.outline > ul { margin-left: 0; }
|
||||
.outline li { margin: 0.3rem 0; line-height: 1.5; }
|
||||
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
|
||||
.outline .kind { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; }
|
||||
.outline .o-note { margin: 0.2rem 0 0 2rem; color: #334155; font-size: 0.95em; }
|
||||
.outline .o-quote { margin: 0.3rem 0 0.3rem 2rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; }
|
||||
.outline .o-attach { margin-left: 2rem; font-size: 0.85em; color: #64748b; }
|
||||
.outline .ref { color: #64748b; font-size: 0.9em; }
|
||||
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
|
||||
@media print {
|
||||
@@ -206,7 +210,7 @@ export function buildExportHtml(project) {
|
||||
|
||||
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
|
||||
const outlineHtml = (project.outline ?? []).length > 0
|
||||
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineListHtml(project.outline, 0)}</section>`
|
||||
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineTimeHtml(project.outline)}${outlineListHtml(project.outline, 0)}</section>`
|
||||
: '';
|
||||
|
||||
const chunksHtml = chapters.map((ch, chapterIndex) => {
|
||||
@@ -268,6 +272,7 @@ export function buildExportHtml(project) {
|
||||
<section class="chunk">
|
||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||
${versesText}
|
||||
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
|
||||
<div class="oia">
|
||||
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
||||
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
||||
@@ -304,8 +309,65 @@ export function buildExportHtml(project) {
|
||||
}
|
||||
|
||||
// <ul> of outline points, each with its label (I., A., 1. …) and passage
|
||||
function outlineTimeHtml(tree) {
|
||||
const total = flattenMinutes(tree);
|
||||
return total > 0 ? `<p class="ref">About ${total} minute${total === 1 ? '' : 's'}</p>` : '';
|
||||
}
|
||||
|
||||
function flattenMinutes(nodes) {
|
||||
return (nodes ?? []).reduce((sum, n) => sum + (Number.isFinite(n.minutes) ? n.minutes : 0) + flattenMinutes(n.children), 0);
|
||||
}
|
||||
|
||||
function outlineListHtml(nodes, depth) {
|
||||
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
|
||||
const one = (n, i) => {
|
||||
const kind = n.kind ? `<span class="kind">${escHtml(outlineKindLabel(n.kind))}</span> ` : '';
|
||||
const mins = Number.isFinite(n.minutes) && n.minutes > 0 ? ` <span class="ref">· ${n.minutes} min</span>` : '';
|
||||
const note = (n.note ?? '').trim() ? `<div class="o-note">${escHtml(n.note.trim()).replace(/\n/g, '<br />')}</div>` : '';
|
||||
const quote = n.scripture?.text ? `<blockquote class="o-quote">“${escHtml(n.scripture.text)}” — ${escHtml(n.scripture.ref)}</blockquote>` : '';
|
||||
const attached = (n.attachments ?? []).length > 0
|
||||
? `<div class="o-attach">${n.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${escHtml(a.label)}${a.detail ? ` — ${escHtml(a.detail)}` : ''}`).join(' · ')}</div>` : '';
|
||||
return `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${kind}${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${mins}${note}${quote}${attached}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`;
|
||||
};
|
||||
return `<ul>${nodes.map(one).join('')}</ul>`;
|
||||
}
|
||||
|
||||
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
|
||||
export function buildOutlineHtml({ title, subtitle = '', tree }) {
|
||||
const total = flattenMinutes(tree);
|
||||
const style = `
|
||||
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
|
||||
.page { max-width: 760px; margin: auto; }
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||
.sub { color: #64748b; margin: 0 0 1.5rem; }
|
||||
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
|
||||
.outline > ul { margin-left: 0; }
|
||||
.outline li { margin: 0.45rem 0; line-height: 1.5; }
|
||||
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
|
||||
.outline > ul > li li { font-weight: 400; }
|
||||
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
|
||||
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
|
||||
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
|
||||
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
|
||||
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
|
||||
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
|
||||
@media print { body { padding: 0; } li { break-inside: avoid; } }
|
||||
`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${escHtml(title)} — Outline</title>
|
||||
<style>${style}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>${escHtml(title)}</h1>
|
||||
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
|
||||
<div class="outline">${outlineListHtml(tree, 0)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function wordTableHtml(rows) {
|
||||
|
||||
+40
-12
@@ -14,7 +14,8 @@ import {
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||
import { flatten } from './outline.js';
|
||||
import { detailLines, flatten, timeSummary } from './outline.js';
|
||||
import { kindLabel } from './outlineLabels.js';
|
||||
|
||||
export function createParagraphsFromText(text) {
|
||||
return text
|
||||
@@ -73,6 +74,37 @@ export function sectionLabel(text) {
|
||||
});
|
||||
}
|
||||
|
||||
// The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it.
|
||||
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => [
|
||||
new Paragraph({
|
||||
indent: { left: 420 * depth, hanging: 420 },
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
|
||||
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
|
||||
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
|
||||
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
|
||||
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
|
||||
],
|
||||
}),
|
||||
...detailLines(node).map((line) => new Paragraph({
|
||||
indent: { left: 420 * depth + 420 },
|
||||
spacing: { after: 40 },
|
||||
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
|
||||
})),
|
||||
]);
|
||||
|
||||
// Just one outline, as its own Word document.
|
||||
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
|
||||
const children = [
|
||||
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
|
||||
...outlineParagraphs(tree),
|
||||
];
|
||||
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
|
||||
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
const children = [
|
||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||
@@ -93,17 +125,8 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
// The outline comes first, indented by level like the page
|
||||
if ((project.outline ?? []).length > 0) {
|
||||
children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } }));
|
||||
flatten(project.outline).forEach(({ node, depth, label }) => {
|
||||
children.push(new Paragraph({
|
||||
indent: { left: 420 * depth, hanging: 420 },
|
||||
spacing: { after: 80 },
|
||||
children: [
|
||||
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
|
||||
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
|
||||
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
|
||||
],
|
||||
}));
|
||||
});
|
||||
if (timeSummary(project.outline)) children.push(new Paragraph({ children: [new TextRun({ text: timeSummary(project.outline), italics: true, color: '7A7060' })], spacing: { after: 120 } }));
|
||||
children.push(...outlineParagraphs(project.outline));
|
||||
}
|
||||
|
||||
project.chapters.forEach((ch, chapterIndex) => {
|
||||
@@ -154,6 +177,11 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
}));
|
||||
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||
|
||||
if ((chunk.outline ?? []).length > 0) {
|
||||
children.push(sectionLabel('Outline'));
|
||||
children.push(...outlineParagraphs(chunk.outline));
|
||||
}
|
||||
|
||||
if (chunk.generalNotes?.trim()) {
|
||||
children.push(sectionLabel('Background / General Notes'));
|
||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getChunkVerseEntries,
|
||||
htmlToPlainText,
|
||||
} from './bibleUtils.jsx';
|
||||
import { outlineToMarkdown } from './outline.js';
|
||||
import { outlineToMarkdown, timeSummary } from './outline.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / prompt builders (exported for testing)
|
||||
@@ -43,13 +43,15 @@ export function buildMarkdownExport(project) {
|
||||
return `- ${summary}${definition}`;
|
||||
}).join('\n')}\n\n`;
|
||||
|
||||
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||
const chunkOutline = (chunk.outline ?? []).length > 0 ? `**Outline**\n\n${outlineToMarkdown(chunk.outline)}\n\n` : '';
|
||||
|
||||
return `### ${scripture}\n\n${versesText}\n\n${chunkOutline}${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||
}).join('---\n\n');
|
||||
|
||||
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||
}).join('\n');
|
||||
|
||||
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${outlineToMarkdown(project.outline)}\n\n` : '';
|
||||
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${timeSummary(project.outline) ? `*${timeSummary(project.outline)}*\n\n` : ''}${outlineToMarkdown(project.outline)}\n\n` : '';
|
||||
return `# ${project.title}\n\n*${project.translation}*\n\n${outline}${chapterSections}`;
|
||||
}
|
||||
|
||||
|
||||
+79
-7
@@ -1,14 +1,18 @@
|
||||
// A study's true outline: main points, sub-points under them, and so on (I. / A. / 1. / a.). It is a tree
|
||||
// of nodes { id, text, ref, chunkId, children } kept on the project as `project.outline`. Every function
|
||||
// returns a new tree and leaves the one it was given alone.
|
||||
import { formatChunkReference, makeId } from './bibleUtils.jsx';
|
||||
import { labelFor, toRoman } from './outlineLabels.js';
|
||||
import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
|
||||
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
|
||||
|
||||
export { labelFor, toRoman };
|
||||
export { KINDS, kindLabel, labelFor, toRoman };
|
||||
|
||||
export const MAX_DEPTH = 5; // levels below the main points
|
||||
|
||||
export const newNode = (text = '', extra = {}) => ({ id: makeId(), text, ref: '', chunkId: null, children: [], ...extra });
|
||||
// A point: its words, an optional passage and chunk link, plus a longer note, a kind, minutes,
|
||||
// the text of a verse, and things attached from the chunks (cross-references, word studies).
|
||||
export const newNode = (text = '', extra = {}) => ({
|
||||
id: makeId(), text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra,
|
||||
});
|
||||
|
||||
// ---- finding ---------------------------------------------------------------
|
||||
|
||||
@@ -136,14 +140,44 @@ export function flatten(tree, depth = 0, parentId = null) {
|
||||
|
||||
export const countNodes = (tree) => flatten(tree).length;
|
||||
|
||||
const pointText = (n) => `${n.text.trim() || '(untitled)'}${n.ref.trim() ? ` (${n.ref.trim()})` : ''}`;
|
||||
const minutesText = (m) => (Number.isFinite(m) && m > 0 ? `${m} min` : '');
|
||||
|
||||
// Total minutes written on the points (blank ones count as nothing)
|
||||
export const totalMinutes = (tree) => flatten(tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0);
|
||||
|
||||
// The headline of a point: "[Illustration] The words (passage) · 5 min"
|
||||
const pointText = (n) => {
|
||||
const kind = n.kind ? `[${kindLabel(n.kind)}] ` : '';
|
||||
const mins = minutesText(n.minutes);
|
||||
return `${kind}${(n.text ?? '').trim() || '(untitled)'}${(n.ref ?? '').trim() ? ` (${n.ref.trim()})` : ''}${mins ? ` · ${mins}` : ''}`;
|
||||
};
|
||||
|
||||
// The lines under a point's headline: its note, verse text and attachments.
|
||||
export function detailLines(node) {
|
||||
const lines = [];
|
||||
for (const l of (node.note ?? '').split('\n')) if (l.trim()) lines.push(l.trim());
|
||||
if (node.scripture?.text) lines.push(`“${node.scripture.text}” — ${node.scripture.ref}`);
|
||||
for (const a of node.attachments ?? []) lines.push(`${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` — ${a.detail}` : ''}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const timeSummary = (tree) => {
|
||||
const total = totalMinutes(tree);
|
||||
return total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : '';
|
||||
};
|
||||
|
||||
export function outlineToText(tree) {
|
||||
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}${label} ${pointText(node)}`).join('\n');
|
||||
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||||
`${' '.repeat(depth)}${label} ${pointText(node)}`,
|
||||
...detailLines(node).map((l) => `${' '.repeat(depth)} ${l}`),
|
||||
]).join('\n');
|
||||
}
|
||||
|
||||
export function outlineToMarkdown(tree) {
|
||||
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}- **${label}** ${pointText(node)}`).join('\n');
|
||||
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||||
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
|
||||
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
|
||||
]).join('\n');
|
||||
}
|
||||
|
||||
// ---- starting one ------------------------------------------------------------
|
||||
@@ -162,3 +196,41 @@ export function seedFromChunks(project, tree = []) {
|
||||
});
|
||||
return [...tree, ...added];
|
||||
}
|
||||
|
||||
// ---- one outline per chunk ---------------------------------------------------
|
||||
// A study has one outline for the whole message (project.outline) and each chunk can have its own
|
||||
// (chunk.outline). `scope` is STUDY or a chunk's id.
|
||||
export const STUDY = 'study';
|
||||
|
||||
export function chunkOf(project, chunkId) {
|
||||
for (const chapter of project?.chapters ?? []) for (const chunk of chapter.chunks ?? []) if (chunk.id === chunkId) return chunk;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const outlineFor = (project, scope) => (scope === STUDY ? project?.outline ?? [] : chunkOf(project, scope)?.outline ?? []);
|
||||
|
||||
// A copy of the project with that scope's outline replaced.
|
||||
export function withOutline(project, scope, tree) {
|
||||
if (scope === STUDY) return { ...project, outline: tree };
|
||||
return {
|
||||
...project,
|
||||
chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === scope ? { ...c, outline: tree } : c)) })),
|
||||
};
|
||||
}
|
||||
|
||||
// One point per verse of the chunk, each carrying that verse's words, for verses not already in `tree`.
|
||||
export function seedFromVerses(project, chunkId, tree = []) {
|
||||
const have = new Set(flatten(tree).map((f) => f.node.scripture?.ref).filter(Boolean));
|
||||
const added = [];
|
||||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||||
for (const chunk of chapter.chunks ?? []) {
|
||||
if (chunk.id !== chunkId) continue;
|
||||
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
|
||||
const ref = `${chapter.book} ${v.chapter}:${v.number}`;
|
||||
if (have.has(ref)) continue;
|
||||
added.push(newNode('', { ref, scripture: { ref, text: v.text, translation: project.translation ?? 'BSB' } }));
|
||||
}
|
||||
}
|
||||
});
|
||||
return [...tree, ...added];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
|
||||
insertAt, locate, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
|
||||
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
|
||||
} from './outline.js';
|
||||
|
||||
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
|
||||
@@ -120,3 +120,60 @@ describe('putting a deleted point back', () => {
|
||||
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('richer points', () => {
|
||||
const rich = [{
|
||||
...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.',
|
||||
scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' },
|
||||
attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }],
|
||||
children: [{ ...n('b', 'Light'), minutes: 3 }],
|
||||
}];
|
||||
test('text and markdown show the kind, minutes, note, verse and attachments', () => {
|
||||
expect(outlineToText(rich)).toBe([
|
||||
'I. [Illustration] Creation (Gen 1) · 5 min',
|
||||
' Tell the story.',
|
||||
' Keep it short.',
|
||||
' “In the beginning” — Genesis 1:1',
|
||||
' See: John 1:1',
|
||||
' Word: bara (H1254) — to create',
|
||||
' A. Light · 3 min',
|
||||
].join('\n'));
|
||||
expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*');
|
||||
});
|
||||
test('totals the minutes, and says nothing when there are none', () => {
|
||||
expect(totalMinutes(rich)).toBe(8);
|
||||
expect(timeSummary(rich)).toBe('About 8 minutes');
|
||||
expect(timeSummary([n('z')])).toBe('');
|
||||
});
|
||||
test('new points start plain, and older ones without the new fields still work', () => {
|
||||
expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] });
|
||||
expect(detailLines(n('old'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an outline for each chunk', () => {
|
||||
const project = {
|
||||
translation: 'BSB', outline: [n('whole')],
|
||||
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [
|
||||
{ id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] },
|
||||
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '' },
|
||||
] }],
|
||||
};
|
||||
test('reads and replaces the outline of the whole study or of one chunk', () => {
|
||||
expect(ids(outlineFor(project, STUDY))).toEqual(['whole']);
|
||||
expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']);
|
||||
expect(outlineFor(project, 'c2')).toEqual([]);
|
||||
const next = withOutline(project, 'c2', [n('new')]);
|
||||
expect(ids(outlineFor(next, 'c2'))).toEqual(['new']);
|
||||
expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']);
|
||||
expect(ids(outlineFor(next, STUDY))).toEqual(['whole']);
|
||||
expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched
|
||||
expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]);
|
||||
});
|
||||
test('seeding from verses adds a point per verse with its words, and never twice', () => {
|
||||
const t = seedFromVerses(project, 'c1', []);
|
||||
expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]);
|
||||
expect(seedFromVerses(project, 'c1', t)).toHaveLength(2);
|
||||
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Taking one outline out of the app: print or save as PDF, share it, or download it.
|
||||
import { buildOutlineHtml } from './bibleUtils.jsx';
|
||||
import { outlineToText } from './outline.js';
|
||||
|
||||
const slug = (s) => String(s).replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'outline';
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// What gets shared as text: a heading, then the outline.
|
||||
export const outlineShareText = ({ title, subtitle = '', tree }) => `${title}${subtitle ? `\n${subtitle}` : ''}\n\n${outlineToText(tree)}`;
|
||||
|
||||
// Opens a print-ready page of just this outline. Returns false when the browser blocked the window.
|
||||
export function printOutline({ title, subtitle, tree }) {
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return false;
|
||||
win.document.write(buildOutlineHtml({ title, subtitle, tree }));
|
||||
win.document.close();
|
||||
win.onload = () => { win.focus(); win.print(); };
|
||||
return true;
|
||||
}
|
||||
|
||||
// Uses the phone/tablet/computer share sheet when there is one (Messages, Mail, AirDrop…), otherwise
|
||||
// copies the text. -> 'shared' | 'copied' | 'cancelled' | 'failed'
|
||||
export async function shareOutline(outline) {
|
||||
const text = outlineShareText(outline);
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: `${outline.title} — Outline`, text });
|
||||
return 'shared';
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return 'cancelled';
|
||||
// fall through to copying
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return 'copied';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadOutlineMarkdown({ title, subtitle, tree }) {
|
||||
const text = `# ${title}\n\n${subtitle ? `*${subtitle}*\n\n` : ''}${tree.length ? outlineToText(tree) : ''}\n`;
|
||||
downloadBlob(new Blob([text], { type: 'text/markdown;charset=utf-8' }), `${slug(title)}-outline.md`);
|
||||
}
|
||||
|
||||
export async function downloadOutlineDocx(outline) {
|
||||
const { buildOutlineDocxBlob } = await import('./docxExport.js'); // the Word library only loads when used
|
||||
downloadBlob(await buildOutlineDocxBlob(outline), `${slug(outline.title)}-outline.docx`);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { outlineShareText, printOutline, shareOutline } from './outlineExport.js';
|
||||
import { buildOutlineHtml } from './bibleUtils.jsx';
|
||||
|
||||
const outline = {
|
||||
title: 'Titus 1 — Elders', subtitle: 'Titus 1:5–9',
|
||||
tree: [{ id: 'a', text: 'Appoint elders', ref: 'Titus 1:5', kind: 'application', minutes: 4, note: 'Why it matters', children: [{ id: 'b', text: 'Blameless', ref: '', children: [] }] }],
|
||||
};
|
||||
beforeEach(() => { vi.stubGlobal('navigator', { ...navigator, share: undefined, clipboard: { writeText: vi.fn(() => Promise.resolve()) } }); });
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
test('the share text has the title, passage and the outline', () => {
|
||||
const text = outlineShareText(outline);
|
||||
expect(text.startsWith('Titus 1 — Elders\nTitus 1:5–9\n\nI. [Application] Appoint elders (Titus 1:5) · 4 min')).toBe(true);
|
||||
expect(text).toContain('A. Blameless');
|
||||
});
|
||||
|
||||
test('uses the system share sheet when there is one', async () => {
|
||||
const share = vi.fn(() => Promise.resolve());
|
||||
vi.stubGlobal('navigator', { share, clipboard: { writeText: vi.fn() } });
|
||||
expect(await shareOutline(outline)).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith(expect.objectContaining({ title: 'Titus 1 — Elders — Outline' }));
|
||||
});
|
||||
|
||||
test('closing the share sheet is not an error, and a broken one falls back to copying', async () => {
|
||||
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(Object.assign(new Error('x'), { name: 'AbortError' }))), clipboard: { writeText: vi.fn() } });
|
||||
expect(await shareOutline(outline)).toBe('cancelled');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(new Error('nope'))), clipboard: { writeText } });
|
||||
expect(await shareOutline(outline)).toBe('copied');
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('without a share sheet it copies, and says so when even that fails', async () => {
|
||||
expect(await shareOutline(outline)).toBe('copied');
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(() => Promise.reject(new Error('denied'))) } });
|
||||
expect(await shareOutline(outline)).toBe('failed');
|
||||
});
|
||||
|
||||
test('print writes a page of just the outline, and reports a blocked window', () => {
|
||||
const doc = { write: vi.fn(), close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||
expect(printOutline(outline)).toBe(true);
|
||||
const html = doc.write.mock.calls[0][0];
|
||||
expect(html).toContain('Appoint elders');
|
||||
expect(html).toContain('About 4 minutes');
|
||||
window.open.mockReturnValue(null);
|
||||
expect(printOutline(outline)).toBe(false);
|
||||
});
|
||||
|
||||
test('the printable page escapes text and shows notes', () => {
|
||||
const html = buildOutlineHtml({ title: 'A <b>', tree: [{ id: 'x', text: '<i>hi</i>', ref: '', note: 'line', children: [] }] });
|
||||
expect(html).toContain('A <b>');
|
||||
expect(html).toContain('<i>hi</i>');
|
||||
expect(html).toContain('class="o-note"');
|
||||
});
|
||||
@@ -26,3 +26,9 @@ export function labelFor(depth, index) {
|
||||
default: return `(${letter(n, false)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export const KINDS = [
|
||||
['', 'Point'], ['illustration', 'Illustration'], ['application', 'Application'],
|
||||
['quote', 'Quote'], ['question', 'Question'], ['transition', 'Transition'],
|
||||
];
|
||||
export const kindLabel = (kind) => KINDS.find(([id]) => id === kind)?.[1] ?? '';
|
||||
@@ -0,0 +1,49 @@
|
||||
// "Titus 1:5–9" -> the words of those verses, for putting scripture inside an outline point.
|
||||
import { matchBooks } from './passageRef.js';
|
||||
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||
import { fetchChapter } from './bibleText.js';
|
||||
|
||||
export const MAX_VERSES = 40; // a whole chapter's worth is plenty to paste into a point
|
||||
|
||||
// -> { book, bookName, chapter, start, end } (start/end null = the whole chapter) or null. Accepts
|
||||
// "Titus 1:5-9", "titus 1:5", "John 3" and a dash or en dash.
|
||||
export function parsePassageRange(text) {
|
||||
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(\d{1,3})(?:\s*[:v ]\s*(\d{1,3})(?:\s*[-–—]\s*(\d{1,3}))?)?$/i);
|
||||
if (!m) return null;
|
||||
const [, name, chapterText, startText, endText] = m;
|
||||
const book = matchBooks(name)[0];
|
||||
const chapter = Number(chapterText);
|
||||
if (!book || chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 1)) return null;
|
||||
const start = startText ? Number(startText) : null;
|
||||
const end = endText ? Number(endText) : start;
|
||||
if (start != null && end < start) return null;
|
||||
return { book: book.abbrev, bookName: bookOptions.find((b) => b.abbrev === book.abbrev)?.name ?? book.name, chapter, start, end };
|
||||
}
|
||||
|
||||
export const formatRange = (r) => `${r.bookName} ${r.chapter}${r.start == null ? '' : r.end === r.start ? `:${r.start}` : `:${r.start}–${r.end}`}`;
|
||||
|
||||
// -> { ref, text, translation }. Throws Error(message) when the passage can't be read or found.
|
||||
export async function fetchPassageText(text, translation = 'BSB') {
|
||||
const range = parsePassageRange(text);
|
||||
if (!range) throw new Error('Type a passage like “Titus 1:5–9”.');
|
||||
const { verses } = await fetchChapter(translation, range.book, range.chapter);
|
||||
const picked = range.start == null ? verses : verses.filter((v) => v.number >= range.start && v.number <= range.end);
|
||||
if (picked.length === 0) throw new Error('Those verses weren’t found in that chapter.');
|
||||
if (picked.length > MAX_VERSES) throw new Error(`That's ${picked.length} verses — pick ${MAX_VERSES} or fewer.`);
|
||||
return { ref: formatRange(range), text: picked.map((v) => v.text).join(' '), translation };
|
||||
}
|
||||
|
||||
// What a chunk offers to attach to a point: its cross-references and the words studied in it.
|
||||
// -> [{ key, type: 'xref' | 'word', label, detail? }]
|
||||
export function attachableFromChunk(chunk) {
|
||||
if (!chunk) return [];
|
||||
const refs = (chunk.crossReferences ?? []).map((r) => ({ key: `x:${r}`, type: 'xref', label: r }));
|
||||
const words = (chunk.greekWords ?? [])
|
||||
.filter((w) => w.lexeme || w.strongNumber || w.query)
|
||||
.map((w) => ({
|
||||
key: `w:${w.strongNumber || w.lexeme || w.query}`, type: 'word',
|
||||
label: [w.lexeme, w.strongNumber && `(${w.strongNumber})`].filter(Boolean).join(' ') || w.query,
|
||||
detail: w.shortDefinition && !/^(No definition|Lookup failed)/.test(w.shortDefinition) ? w.shortDefinition : undefined,
|
||||
}));
|
||||
return [...refs, ...words];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { attachableFromChunk, fetchPassageText, parsePassageRange } from './passageText.js';
|
||||
|
||||
const chapter = { chapter: { content: [1, 2, 3, 4, 5].map((n) => ({ type: 'verse', number: n, content: [`Verse ${n} text.`] })) } };
|
||||
beforeEach(() => vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(chapter) }))));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test('reads a verse, a range, or a whole chapter', () => {
|
||||
expect(parsePassageRange('Titus 1:5')).toMatchObject({ book: 'TIT', chapter: 1, start: 5, end: 5 });
|
||||
expect(parsePassageRange('titus 1:5-9')).toMatchObject({ start: 5, end: 9 });
|
||||
expect(parsePassageRange('1 Cor 13:4–7')).toMatchObject({ book: '1CO', chapter: 13, start: 4, end: 7 });
|
||||
expect(parsePassageRange('John 3')).toMatchObject({ chapter: 3, start: null });
|
||||
});
|
||||
|
||||
test('rejects things that are not passages, or backwards ranges', () => {
|
||||
expect(parsePassageRange('hello')).toBeNull();
|
||||
expect(parsePassageRange('Titus 9:1')).toBeNull();
|
||||
expect(parsePassageRange('Titus 1:9-5')).toBeNull();
|
||||
});
|
||||
|
||||
test('fetches just the verses asked for, and names the passage', async () => {
|
||||
const got = await fetchPassageText('Titus 1:2-3');
|
||||
expect(got).toEqual({ ref: 'Titus 1:2–3', text: 'Verse 2 text. Verse 3 text.', translation: 'BSB' });
|
||||
});
|
||||
|
||||
test('says so when the verses are not there, or the passage is not understood', async () => {
|
||||
await expect(fetchPassageText('Titus 1:50')).rejects.toThrow(/weren’t found/);
|
||||
await expect(fetchPassageText('nonsense')).rejects.toThrow(/Type a passage/);
|
||||
});
|
||||
|
||||
test('offers a chunk\'s cross-references and studied words to attach', () => {
|
||||
const chunk = {
|
||||
crossReferences: ['John 1:1'],
|
||||
greekWords: [
|
||||
{ strongNumber: 'G1401', lexeme: 'δοῦλος', shortDefinition: 'a slave' },
|
||||
{ query: 'xyz', shortDefinition: 'No definition found.' },
|
||||
],
|
||||
};
|
||||
expect(attachableFromChunk(chunk)).toEqual([
|
||||
{ key: 'x:John 1:1', type: 'xref', label: 'John 1:1' },
|
||||
{ key: 'w:G1401', type: 'word', label: 'δοῦλος (G1401)', detail: 'a slave' },
|
||||
{ key: 'w:xyz', type: 'word', label: 'xyz', detail: undefined },
|
||||
]);
|
||||
expect(attachableFromChunk(null)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
|
||||
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
|
||||
|
||||
// Words with their position in the original text: [{ word: 'light', start, end }]
|
||||
export function tokenize(text) {
|
||||
const out = [];
|
||||
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
|
||||
let m;
|
||||
while ((m = re.exec(text ?? '')) !== null) {
|
||||
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Longest common subsequence of two word lists -> the indexes kept in each (classic DP; verses are short).
|
||||
function commonWords(a, b) {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const inA = new Set();
|
||||
const inB = new Set();
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1;
|
||||
else j += 1;
|
||||
}
|
||||
return { inA, inB };
|
||||
}
|
||||
|
||||
// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many.
|
||||
function ranges(tokens, kept, text) {
|
||||
const out = [];
|
||||
tokens.forEach((t, i) => {
|
||||
if (kept.has(i)) return;
|
||||
const last = out[out.length - 1];
|
||||
if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end;
|
||||
else out.push({ start: t.start, end: t.end });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool }
|
||||
// `a` and `b` are the character ranges (in each text) of words the other does not have.
|
||||
export function diffVerses(textA, textB) {
|
||||
const a = tokenize(textA);
|
||||
const b = tokenize(textB);
|
||||
if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true };
|
||||
const { inA, inB } = commonWords(a, b);
|
||||
const similarity = (2 * inA.size) / (a.length + b.length);
|
||||
return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length };
|
||||
}
|
||||
|
||||
// A whole chapter: pairs of [mainVerse, otherVerse] by verse number ->
|
||||
// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first }
|
||||
export function diffChapter(mainVerses, otherVerses) {
|
||||
const other = new Map(otherVerses.map((v) => [v.number, v]));
|
||||
let shared = 0;
|
||||
let words = 0;
|
||||
const verses = [];
|
||||
for (const v of mainVerses) {
|
||||
const o = other.get(v.number);
|
||||
if (!o) continue;
|
||||
const d = diffVerses(v.text, o.text);
|
||||
const na = tokenize(v.text).length;
|
||||
const nb = tokenize(o.text).length;
|
||||
shared += (d.similarity * (na + nb)) / 2;
|
||||
words += (na + nb) / 2;
|
||||
verses.push({ number: v.number, ...d });
|
||||
}
|
||||
return {
|
||||
similarity: words === 0 ? 1 : shared / words,
|
||||
total: verses.length,
|
||||
differing: verses.filter((v) => !v.same).length,
|
||||
verses,
|
||||
mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
// "12% different" for a similarity of 0.88; "identical words" at 1.
|
||||
export const differenceLabel = (similarity) => {
|
||||
const pct = Math.round((1 - similarity) * 100);
|
||||
return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js';
|
||||
|
||||
const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end));
|
||||
|
||||
describe('tokenize', () => {
|
||||
test('words only, lowercased, with their place in the text', () => {
|
||||
const t = tokenize('God said, “Let there be light,”');
|
||||
expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']);
|
||||
expect(t[0]).toMatchObject({ start: 0, end: 3 });
|
||||
});
|
||||
test('possessives and apostrophes do not split or change words', () => {
|
||||
expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']);
|
||||
expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffVerses', () => {
|
||||
test('identical wording, whatever the punctuation or case, is the same', () => {
|
||||
const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth');
|
||||
expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] });
|
||||
});
|
||||
test('marks the words each side has that the other does not', () => {
|
||||
const a = 'The earth was formless and void';
|
||||
const b = 'The earth was without shape and empty';
|
||||
const d = diffVerses(a, b);
|
||||
expect(slice(a, d.a)).toEqual(['formless', 'void']);
|
||||
expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range
|
||||
expect(d.same).toBe(false);
|
||||
expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5);
|
||||
});
|
||||
test('a pure addition shows only on the side that has it', () => {
|
||||
const d = diffVerses('God saw the light', 'God saw that the light was good');
|
||||
expect(d.a).toEqual([]);
|
||||
expect(d.b.length).toBeGreaterThan(0);
|
||||
});
|
||||
test('totally different text is not alike at all; two empty ones are the same', () => {
|
||||
expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0);
|
||||
expect(diffVerses('', '').same).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffChapter', () => {
|
||||
const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }];
|
||||
const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }];
|
||||
test('scores the chapter over verses both have, and lists the most different first', () => {
|
||||
const c = diffChapter(main, other);
|
||||
expect(c.total).toBe(2); // verse 3 and 4 are not in both
|
||||
expect(c.differing).toBe(1);
|
||||
expect(c.mostDifferent.map((v) => v.number)).toEqual([2]);
|
||||
expect(c.similarity).toBeGreaterThan(0.7);
|
||||
expect(c.similarity).toBeLessThan(1);
|
||||
});
|
||||
test('an identical chapter is fully alike', () => {
|
||||
expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('differenceLabel', () => {
|
||||
test('says it in percent, and never "0% different" for something that does differ', () => {
|
||||
expect(differenceLabel(0.88)).toBe('12% different');
|
||||
expect(differenceLabel(1)).toBe('Same words');
|
||||
expect(differenceLabel(0.9999)).toBe('<1% different');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user