diff --git a/public/verse-backgrounds/README.txt b/public/verse-backgrounds/README.txt new file mode 100644 index 0000000..9038a33 --- /dev/null +++ b/public/verse-backgrounds/README.txt @@ -0,0 +1,15 @@ +Photos for behind the verse of the day and the "Share as image" pictures. + +1. Drop landscape photos (about 1200px wide or larger, .jpg/.png/.webp) into this folder. +2. List them in index.json, for example: + + [ + { "file": "mountain-lake.jpg", "name": "Mountain lake" }, + { "file": "wheat.jpg", "name": "Wheat" } + ] + + (A plain list of file names also works: ["mountain-lake.jpg", "wheat.jpg"].) + +They then appear under "Your photos" in the background menus, and "Picture of the day" rotates through them +along with the built-in scenes. With no index.json the built-in scenes are used. Free sources: pixabay.com, +unsplash.com. Check each photo's licence before sharing pictures made from it. diff --git a/public/verse-backgrounds/index.json b/public/verse-backgrounds/index.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/public/verse-backgrounds/index.json @@ -0,0 +1 @@ +[] diff --git a/src/components/BookCover.jsx b/src/components/BookCover.jsx new file mode 100644 index 0000000..9188dd4 --- /dev/null +++ b/src/components/BookCover.jsx @@ -0,0 +1,27 @@ +// The picture on a study's card: a coloured cover for the kind of book, with the book's name. +import { coverFor } from '../utils/bookCovers.js'; + +export default function BookCover({ summary, className = '' }) { + const { group, name, extra } = coverFor(summary); + const [from, to] = group.colors; + const id = `cover-${group.id}`; + return ( +
+ +
+ ); +} diff --git a/src/components/VerseShareImage.jsx b/src/components/VerseShareImage.jsx new file mode 100644 index 0000000..9e0a2f9 --- /dev/null +++ b/src/components/VerseShareImage.jsx @@ -0,0 +1,81 @@ +// "Share as image": previews a verse card, lets you pick its look (plain theme, a scene, one of your +// photos, or a photo from your device), then shares or saves the picture. +import { useEffect, useRef, useState } from 'react'; +import { READER_THEMES } from './reader/readerThemes.js'; +import { renderVerseCard, shareOrSaveImage, verseCardFileName } from '../utils/verseCard.js'; +import { SCENES, loadImage, loadPhotoList, resolveBackground } from '../utils/verseBackgrounds.js'; +import { useToast } from './Toasts.jsx'; + +const STYLE_KEY = 'verse-card-style'; +export const BACKGROUND_KEY = 'votd-background'; + +export default function VerseShareImage({ text, reference, translation, notice = '', defaultStyle = 'paper', defaultBackground = 'plain', onClose }) { + const { toast } = useToast(); + const [style, setStyle] = useState(() => { try { return localStorage.getItem(STYLE_KEY) || defaultStyle; } catch { return defaultStyle; } }); + const [choice, setChoice] = useState(defaultBackground); // 'plain' | 'auto' | scene id | photo id | 'mine' + const [photos, setPhotos] = useState([]); + const [mine, setMine] = useState(null); // a photo picked from this device: { image, name } + const [image, setImage] = useState({ blob: null, url: '', error: '' }); + const fileRef = useRef(null); + + useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []); + + useEffect(() => { + let live = true; + let url = ''; + const background = choice === 'mine' && mine ? { kind: 'image', image: mine.image } : resolveBackground(choice === 'mine' ? 'plain' : choice, photos); + renderVerseCard({ text, reference, translation, notice, themeId: style, background }) + .then((blob) => { if (!live) return; url = URL.createObjectURL(blob); setImage({ blob, url, error: '' }); }) + .catch((err) => { if (live) setImage({ blob: null, url: '', error: err.message }); }); + return () => { live = false; if (url) URL.revokeObjectURL(url); }; + }, [text, reference, translation, notice, style, choice, photos, mine]); + + const chooseStyle = (id) => { setStyle(id); setChoice('plain'); try { localStorage.setItem(STYLE_KEY, id); } catch { /* storage unavailable */ } }; + const pickPhoto = async (e) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + const url = URL.createObjectURL(file); + try { setMine({ image: await loadImage(url), name: file.name }); setChoice('mine'); } catch (err) { toast(err.message, { tone: 'error' }); } finally { URL.revokeObjectURL(url); } + }; + const go = async () => { + const result = await shareOrSaveImage(image.blob, verseCardFileName(reference), { title: reference, text: `${reference} (${translation})` }); + if (result === 'saved') toast('Image saved'); + }; + + return ( +
+
+ + {onClose && } +
+ + {choice === 'plain' && ( +
+ {Object.entries(READER_THEMES).map(([id, t]) => ( + + ))} +
+ )} + {image.error ?

{image.error}

+ : image.url ? {`${reference} + :

Making the image…

} +
+ +
+
+ ); +} diff --git a/src/components/VerseShareImage.test.jsx b/src/components/VerseShareImage.test.jsx new file mode 100644 index 0000000..6a890ae --- /dev/null +++ b/src/components/VerseShareImage.test.jsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; + +vi.mock('../utils/verseCard.js', async (orig) => ({ + ...(await orig()), + renderVerseCard: vi.fn(async () => new Blob(['png'], { type: 'image/png' })), + shareOrSaveImage: vi.fn(async () => 'saved'), +})); +vi.mock('./Toasts.jsx', () => ({ useToast: () => ({ toast: vi.fn() }) })); +import VerseShareImage from './VerseShareImage.jsx'; +import { renderVerseCard, shareOrSaveImage } from '../utils/verseCard.js'; + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + URL.createObjectURL = vi.fn(() => 'blob:card'); + URL.revokeObjectURL = vi.fn(); +}); + +const props = { text: 'For God so loved the world.', reference: 'John 3:16', translation: 'BSB' }; + +test('previews the card, redraws when the look changes, and remembers the look', async () => { + const user = userEvent.setup(); + render(); + expect(await screen.findByAltText(/John 3:16 \(BSB\) as a picture/)).toBeInTheDocument(); + expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'paper', reference: 'John 3:16' })); + await user.click(screen.getByRole('button', { name: 'Dark' })); + await screen.findByAltText(/as a picture/); + expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'dark' })); + expect(localStorage.getItem('verse-card-style')).toBe('dark'); +}); + +test('Save/Share sends the image under a file name from the reference', async () => { + const user = userEvent.setup(); + render(); + await screen.findByAltText(/as a picture/); + await user.click(screen.getByRole('button', { name: /save image|share image/i })); + expect(shareOrSaveImage).toHaveBeenCalledWith(expect.any(Blob), 'john-3-16.png', expect.objectContaining({ title: 'John 3:16' })); +}); + +test('says so if the picture can\'t be drawn', async () => { + renderVerseCard.mockRejectedValueOnce(new Error('This browser can’t draw the image.')); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent(/can’t draw/); +}); + +test('a background picture can be chosen, and is passed to the card; the plain looks come back with Plain', async () => { + const user = userEvent.setup(); + render(); + await screen.findByAltText(/as a picture/); + expect(screen.getByRole('group', { name: /image style/i })).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText('Image background'), 'sea'); + await screen.findByAltText(/as a picture/); + expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: { kind: 'scene', id: 'sea' } })); + expect(screen.queryByRole('group', { name: /image style/i })).toBeNull(); // theme looks only apply to the plain card + await user.selectOptions(screen.getByLabelText('Image background'), 'plain'); + expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: null })); +}); + +test('the picture of the day is used when the Home card passes it in', async () => { + render(); + await screen.findByAltText(/as a picture/); + expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: expect.objectContaining({ kind: 'scene' }) })); +}); diff --git a/src/components/iq/IqDailyCard.jsx b/src/components/iq/IqDailyCard.jsx index 8278825..c753a0b 100644 --- a/src/components/iq/IqDailyCard.jsx +++ b/src/components/iq/IqDailyCard.jsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import { useApp } from '../../context/AppContext.js'; import { useIq, useIqAvailable } from '../../hooks/useIq.js'; +import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx'; +import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js'; import { bookOptions } from '../../utils/bibleUtils.jsx'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; @@ -12,7 +14,7 @@ export default function IqDailyCard() { } function Card() { - const { jumpToReaderVerse, openBibleReader, readerTranslation } = useApp(); + const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp(); // Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick. const votd = useIq('bibles/eng_kjv/verse-of-the-day'); const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } }); @@ -20,6 +22,14 @@ function Card() { const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } }; const ref = votd.data?.data; const [text, setText] = useState({ key: '', value: '', error: false }); + const [sharing, setSharing] = useState(false); + // The picture behind the verse: a different one each day by default, or the one you chose. + const [bgChoice, setBgChoice] = useState(() => { try { return localStorage.getItem(BACKGROUND_KEY) || 'auto'; } catch { return 'auto'; } }); + const [photos, setPhotos] = useState([]); + useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []); + const background = resolveBackground(bgChoice, photos); + const backgroundUrl = background?.kind === 'photo' ? background.url : background ? sceneDataUrl(background.id) : ''; + const pickBackground = (id) => { setBgChoice(id); try { localStorage.setItem(BACKGROUND_KEY, id); } catch { /* storage unavailable */ } }; const key = ref ? `${translation}/${ref.book_usfm}/${ref.chapter_number}/${ref.verse_number}` : ''; useEffect(() => { if (!ref) return undefined; @@ -36,6 +46,9 @@ function Card() { const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening')); const verse = votd.data?.data; const reading = dev.data?.data?.[part]; + const loaded = text.key === key; + const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…'; + const shownTranslation = loaded && text.error ? 'KJV' : translation; const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev; if (!verse && !reading) return null; return ( @@ -44,16 +57,42 @@ function Card() {

Verse of the day

+ + +
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */} -

{text.key === key && text.value ? text.value : text.key === key && text.error ? verse.text : '…'}

- {text.key === key && text.error && translation !== 'KJV' &&

Couldn’t load {translation} just now, so this is the KJV.

} - + {backgroundUrl ? ( +
+
+
{shown}
+
{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} · {shownTranslation}
+
+
+ ) : ( +
{shown}
+ )} + {loaded && text.error && translation !== 'KJV' &&

Couldn’t load {translation} just now, so this is the KJV.

} +
+ + +
+ {sharing && loaded && shown && ( + t.id === translation)?.notice ?? '' : ''} defaultStyle={readerTheme || 'paper'} defaultBackground={bgChoice} onClose={() => setSharing(false)} /> + )} {text.key === key && text.value && READER_TRANSLATIONS.find((t) => t.id === translation)?.notice && (

{READER_TRANSLATIONS.find((t) => t.id === translation).notice}

)} diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx index f9cc333..6b7f7ab 100644 --- a/src/components/reader/VerseSheet.jsx +++ b/src/components/reader/VerseSheet.jsx @@ -10,6 +10,7 @@ import { diffVerses, differenceLabel } from '../../utils/textDiff.js'; import { DiffText } from './DiffText.jsx'; import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js'; import { useIqAvailable } from '../../hooks/useIq.js'; +import VerseShareImage from '../VerseShareImage.jsx'; import IqLexicon from '../iq/IqLexicon.jsx'; import IqStudyAids from '../iq/IqStudyAids.jsx'; @@ -160,6 +161,7 @@ export default function VerseSheet({ const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS; const [tab, setTab] = useState('commentary'); const [copied, setCopied] = useState(false); + const [imageOpen, setImageOpen] = useState(false); const [preview, setPreview] = useState(null); // the cross-reference being read in the panel const appliedRequest = useRef(0); // Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the @@ -177,7 +179,7 @@ export default function VerseSheet({ } }, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { setCopied(false); }, [verse?.number]); + useEffect(() => { setCopied(false); setImageOpen(false); }, [verse?.number]); if (!verse) return null; const copy = () => { @@ -201,12 +203,21 @@ export default function VerseSheet({ )} + )} > + {imageOpen && ( +
+ t.id === translation)?.notice ?? ''} onClose={() => setImageOpen(false)} /> +
+ )} {onHighlightVerse && ( // One-tap highlighting of the whole verse (to mark just some words, select them in the text)
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index b6c58db..6a63db8 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useApp } from '../context/AppContext.js'; +import BookCover from '../components/BookCover.jsx'; import IqDailyCard from '../components/iq/IqDailyCard.jsx'; import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx'; import PageShell from '../components/study/PageShell.jsx'; @@ -201,6 +202,7 @@ export default function HomePage() {
{visible.map((entry) => (
+
{renamingId === entry.id ? (
diff --git a/src/utils/bookCovers.js b/src/utils/bookCovers.js new file mode 100644 index 0000000..9c4a35a --- /dev/null +++ b/src/utils/bookCovers.js @@ -0,0 +1,40 @@ +// A cover for each study, named for the book it covers. The picture is drawn (a colour for the kind of book, +// a soft pattern, the book's name), so it works offline and needs no image files. +import { bookOptions } from './bibleUtils.jsx'; + +const GROUPS = [ + { id: 'law', label: 'Law', colors: ['#0f766e', '#134e4a'], books: ['GEN', 'EXO', 'LEV', 'NUM', 'DEU'] }, + { id: 'history', label: 'History', colors: ['#3b5b8c', '#1e3a5f'], books: ['JOS', 'JDG', 'RUT', '1SA', '2SA', '1KI', '2KI', '1CH', '2CH', 'EZR', 'NEH', 'EST'] }, + { id: 'wisdom', label: 'Wisdom', colors: ['#7e3f8f', '#4a1d5c'], books: ['JOB', 'PSA', 'PRO', 'ECC', 'SNG'] }, + { id: 'major', label: 'Major prophets', colors: ['#9f2f3f', '#5e1420'], books: ['ISA', 'JER', 'LAM', 'EZK', 'DAN'] }, + { id: 'minor', label: 'Minor prophets', colors: ['#b4531f', '#6e2c0c'], books: ['HOS', 'JOL', 'AMO', 'OBA', 'JON', 'MIC', 'NAM', 'HAB', 'ZEP', 'HAG', 'ZEC', 'MAL'] }, + { id: 'gospel', label: 'Gospel', colors: ['#b7791f', '#6b4410'], books: ['MAT', 'MRK', 'LUK', 'JHN'] }, + { id: 'acts', label: 'Acts', colors: ['#2f7d4f', '#14442a'], books: ['ACT'] }, + { id: 'paul', label: 'Letter of Paul', colors: ['#2b4a8f', '#142550'], books: ['ROM', '1CO', '2CO', 'GAL', 'EPH', 'PHP', 'COL', '1TH', '2TH', '1TI', '2TI', 'TIT', 'PHM'] }, + { id: 'general', label: 'General letter', colors: ['#4f46a5', '#27235e'], books: ['HEB', 'JAS', '1PE', '2PE', '1JN', '2JN', '3JN', 'JUD'] }, + { id: 'revelation', label: 'Revelation', colors: ['#a31d3d', '#4b0a1c'], books: ['REV'] }, +]; +const FALLBACK = { id: 'other', label: 'Study', colors: ['#57534e', '#292524'], books: [] }; + +export const groupOfBook = (abbrev) => GROUPS.find((g) => g.books.includes(abbrev)) ?? FALLBACK; + +// The book a study opens with, found from its passage summary ("Titus 1, Titus 2" or "1 John 2"), or null. +export function bookFromSummary(summary) { + const first = String(summary ?? '').split(',')[0].trim(); + if (!first) return null; + const named = [...bookOptions].sort((a, b) => b.name.length - a.name.length); + return named.find((b) => first === b.name || first.startsWith(`${b.name} `)) ?? null; +} + +// Every distinct book in a summary, in order ("John 3, Romans 8" -> [John, Romans]). +export function booksFromSummary(summary) { + const seen = new Set(); + return String(summary ?? '').split(',').map((part) => bookFromSummary(part)).filter((b) => b && !seen.has(b.abbrev) && seen.add(b.abbrev)); +} + +export function coverFor(summary) { + const books = booksFromSummary(summary); + const book = books[0] ?? null; + const group = groupOfBook(book?.abbrev); + return { book, group, name: book?.name ?? 'Bible study', extra: Math.max(0, books.length - 1) }; +} diff --git a/src/utils/bookCovers.test.js b/src/utils/bookCovers.test.js new file mode 100644 index 0000000..32fabce --- /dev/null +++ b/src/utils/bookCovers.test.js @@ -0,0 +1,31 @@ +import { test, expect } from 'vitest'; +import { bookFromSummary, booksFromSummary, coverFor, groupOfBook } from './bookCovers.js'; +import { wrapLines, fitText } from './verseCard.js'; + +test('the book comes from the study\'s passage summary, including numbered books', () => { + expect(bookFromSummary('Titus 1, Titus 2').abbrev).toBe('TIT'); + expect(bookFromSummary('1 John 2').abbrev).toBe('1JN'); + expect(bookFromSummary('Song of Solomon 2').abbrev).toBe('SNG'); + expect(bookFromSummary('Nowhere 3')).toBeNull(); + expect(bookFromSummary('')).toBeNull(); + expect(booksFromSummary('John 3, John 4, Romans 8').map((b) => b.abbrev)).toEqual(['JHN', 'ROM']); +}); + +test('every book has a cover colour group, and unknown ones get a plain cover', () => { + expect(groupOfBook('GEN').id).toBe('law'); + expect(groupOfBook('PSA').id).toBe('wisdom'); + expect(groupOfBook('REV').id).toBe('revelation'); + expect(groupOfBook('XXX').id).toBe('other'); + expect(coverFor('John 3, Romans 8')).toMatchObject({ name: 'John', extra: 1 }); + expect(coverFor('').name).toBe('Bible study'); +}); + +test('verse text wraps to the width and shrinks to fit', () => { + const measure = (s) => s.length * 10; + expect(wrapLines('For God so loved the world', 100, measure)).toEqual(['For God so', 'loved the', 'world']); + expect(wrapLines('Supercalifragilistic word', 50, measure)).toEqual(['Supercalifragilistic', 'word']); + const text = 'word '.repeat(40).trim(); + const fit = fitText(text, { maxWidth: 400, maxHeight: 300, maxSize: 60, minSize: 20, measureAt: (size, s) => s.length * size * 0.5 }); + expect(fit.size).toBeLessThan(60); + expect(fit.lines.length * fit.size * fit.lineHeight).toBeLessThanOrEqual(300); +}); diff --git a/src/utils/outline.js b/src/utils/outline.js index 8c35ac0..9448e99 100644 --- a/src/utils/outline.js +++ b/src/utils/outline.js @@ -214,6 +214,20 @@ const MAX_ITEMS = 15; const HEADLINE = 110; const MARKER = /^\s*(?:[-*•–]|\d{1,2}[.)]|[a-z][.)])\s+/i; +// The first sentence of a line: up to a . ! or ? that is followed by a capital letter, skipping abbreviations +// such as "A.D." or "e.g." and numbers like "63.". +const ABBREVIATIONS = new Set(['mr', 'mrs', 'ms', 'dr', 'st', 'vs', 'v', 'vv', 'cf', 'ch', 'etc', 'no', 'e.g', 'i.e', 'a.d', 'b.c', 'c', 'ca']); +export function firstSentence(full) { + const end = /[.!?]["'”’)\]]*\s+(?=["'“‘(]?[A-Z])/g; + let m; + while ((m = end.exec(full))) { + const before = full.slice(0, m.index + 1).split(/\s+/).pop().replace(/[.!?]+$/, '').toLowerCase(); + if (m.index < 19 || ABBREVIATIONS.has(before) || /^[a-z]$/.test(before)) continue; + return full.slice(0, m.index + m[0].match(/^[.!?]["'”’)\]]*/)[0].length); + } + return full; +} + // One note box -> [{ text, note }]: a point per line (bullet markers dropped, indented lines join the line above). export function notesToPoints(raw) { const items = []; @@ -223,7 +237,7 @@ export function notesToPoints(raw) { items.push(line.replace(MARKER, '').trim()); } return items.filter(Boolean).slice(0, MAX_ITEMS).map((full) => { - const sentence = /^(.{20,}?[.!?])(\s+|$)/.exec(full)?.[1] ?? full; + const sentence = firstSentence(full); let headline = sentence; if (headline.length > HEADLINE) headline = `${headline.slice(0, HEADLINE).replace(/\s+\S*$/, '')}…`; const rest = headline.endsWith('…') ? full : full.slice(sentence.length).trim(); diff --git a/src/utils/outline.test.js b/src/utils/outline.test.js index 39a2675..cbac061 100644 --- a/src/utils/outline.test.js +++ b/src/utils/outline.test.js @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent, - STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, notesToPoints, buildFromNotes, buildChunkFromNotes, notesBuildCount, + STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, notesToPoints, firstSentence, buildFromNotes, buildChunkFromNotes, notesBuildCount, } from './outline.js'; const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children }); @@ -179,6 +179,13 @@ describe('an outline for each chunk', () => { }); describe('building from the notes', () => { + test('abbreviations and numbers do not end a sentence', () => { + expect(firstSentence('Written by Paul to Titus on Crete, about A.D. 63. Crete was unruly.')).toBe('Written by Paul to Titus on Crete, about A.D. 63. Crete was unruly.'.replace(' Crete was unruly.', '')); + expect(firstSentence('He wrote this letter to Titus, e.g. as a guide. Then he closed it.')).toBe('He wrote this letter to Titus, e.g. as a guide.'); + expect(firstSentence('Short. Then more words follow after that, which is fine.')).toBe('Short. Then more words follow after that, which is fine.'); + expect(notesToPoints('Written by Paul to Titus on Crete, about A.D. 63.')).toEqual([{ text: 'Written by Paul to Titus on Crete, about A.D. 63', note: '' }]); + }); + const chunkWith = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: '', interpretation: '', application: '', generalNotes: '', crossReferences: [], greekWords: [], ...extra }); const study = (chunks) => ({ id: 's', title: 'T', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks }] }); diff --git a/src/utils/verseBackgrounds.js b/src/utils/verseBackgrounds.js new file mode 100644 index 0000000..1de17cd --- /dev/null +++ b/src/utils/verseBackgrounds.js @@ -0,0 +1,317 @@ +// Pictures to put behind a verse: scenes drawn in the app (so they work offline), and any photos dropped into +// public/verse-backgrounds (listed in its index.json). The Home card and the shareable image both use these. + +// A small repeatable random-number generator, so a scene looks the same every time it is drawn. +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const sky = (ctx, w, h, stops, to = 0.75) => { + const g = ctx.createLinearGradient(0, 0, 0, h * to); + stops.forEach(([at, color]) => g.addColorStop(at, color)); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); +}; + +const glow = (ctx, x, y, r, color) => { + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, color); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); +}; + +// A ridge line across the picture, filled below with `color`. +function ridge(ctx, w, h, { base, amp, detail = 6, color, seed }) { + const r = rng(seed); + const phases = Array.from({ length: detail }, () => [r() * Math.PI * 2, 0.5 + r() * 1.8]); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(0, h); + for (let x = 0; x <= w; x += 6) { + let y = 0; + phases.forEach(([p, f], i) => { y += Math.sin((x / w) * f * Math.PI * 2 + p) / (i + 1); }); + ctx.lineTo(x, base + y * amp * 0.55); + } + ctx.lineTo(w, h); + ctx.closePath(); + ctx.fill(); +} + +// A row of fir trees standing on `base`, as stacked triangles. +function firs(ctx, w, { base, size, color, seed, count }) { + const r = rng(seed); + ctx.fillStyle = color; + for (let i = 0; i < count; i += 1) { + const x = (i / count) * w + (r() - 0.5) * (w / count) * 1.4; + const h = size * (0.6 + r() * 0.7); + const wd = h * 0.38; + for (let tier = 0; tier < 4; tier += 1) { + const top = base - h + tier * h * 0.22; + const bottom = base - h + (tier + 1.4) * h * 0.26; + ctx.beginPath(); + ctx.moveTo(x, top); + ctx.lineTo(x - wd * (0.45 + tier * 0.22), bottom); + ctx.lineTo(x + wd * (0.45 + tier * 0.22), bottom); + ctx.closePath(); + ctx.fill(); + } + ctx.fillRect(x - 1.5, base - h * 0.12, 3, h * 0.14); + } +} + +const fog = (ctx, w, y, height, alpha = 0.5, color = '255,255,255') => { + const g = ctx.createLinearGradient(0, y, 0, y + height); + g.addColorStop(0, `rgba(${color},0)`); + g.addColorStop(0.5, `rgba(${color},${alpha})`); + g.addColorStop(1, `rgba(${color},0)`); + ctx.fillStyle = g; + ctx.fillRect(0, y, w, height); +}; + +// A soft cloud: overlapping translucent circles. +function cloud(ctx, x, y, size, alpha, color = '255,255,255', seed = 1) { + const r = rng(seed); + for (let i = 0; i < 9; i += 1) { + const cx = x + (r() - 0.5) * size * 1.6; + const cy = y + (r() - 0.5) * size * 0.35; + const rad = size * (0.25 + r() * 0.3); + const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad); + g.addColorStop(0, `rgba(${color},${alpha})`); + g.addColorStop(1, `rgba(${color},0)`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.fill(); + } +} + +export const SCENES = [ + { id: 'dawn', name: 'Sunrise', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#1d2a5b'], [0.45, '#8f5a8e'], [0.75, '#f0866a'], [1, '#f9d58a']], 0.8); + glow(ctx, w * 0.68, h * 0.66, h * 0.55, 'rgba(255,236,170,0.95)'); + ridge(ctx, w, h, { base: h * 0.72, amp: h * 0.12, color: '#4a2f55', seed: 11 }); + ridge(ctx, w, h, { base: h * 0.82, amp: h * 0.1, color: '#2a1c3c', seed: 12 }); + } }, + { id: 'hills', name: 'Green hills', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#7fb8e6'], [1, '#eaf6ff']], 0.7); + glow(ctx, w * 0.8, h * 0.18, h * 0.4, 'rgba(255,250,214,0.9)'); + ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.1, color: '#a8d19a', seed: 21 }); + ridge(ctx, w, h, { base: h * 0.68, amp: h * 0.12, color: '#79b36f', seed: 22 }); + ridge(ctx, w, h, { base: h * 0.8, amp: h * 0.1, color: '#4f9150', seed: 23 }); + ridge(ctx, w, h, { base: h * 0.92, amp: h * 0.07, color: '#2f6d3b', seed: 24 }); + } }, + { id: 'sea', name: 'Sea at sunset', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#33316b'], [0.5, '#d6577b'], [1, '#ffb36b']], 0.66); + glow(ctx, w * 0.5, h * 0.62, h * 0.5, 'rgba(255,230,160,0.95)'); + const sea = ctx.createLinearGradient(0, h * 0.64, 0, h); + sea.addColorStop(0, '#e98a5e'); sea.addColorStop(0.35, '#6b4a8a'); sea.addColorStop(1, '#1f2352'); + ctx.fillStyle = sea; + ctx.fillRect(0, h * 0.64, w, h * 0.36); + const r = rng(33); + for (let i = 0; i < 70; i += 1) { + const y = h * 0.65 + r() * h * 0.33; + const spread = 0.04 + ((y - h * 0.64) / h) * 0.5; + ctx.fillStyle = `rgba(255,224,170,${0.55 - (y / h - 0.64) * 1.2})`; + ctx.fillRect(w * 0.5 - (r() * spread * w) / 2, y, r() * spread * w, 2); + } + } }, + { id: 'mountains', name: 'Mountains', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#5e8fc7'], [1, '#e7f0f8']], 0.75); + glow(ctx, w * 0.3, h * 0.25, h * 0.35, 'rgba(255,255,255,0.7)'); + ridge(ctx, w, h, { base: h * 0.5, amp: h * 0.28, detail: 9, color: '#9db4cf', seed: 41 }); + ridge(ctx, w, h, { base: h * 0.64, amp: h * 0.24, detail: 8, color: '#6f8cb0', seed: 42 }); + ridge(ctx, w, h, { base: h * 0.8, amp: h * 0.16, detail: 7, color: '#3f5f87', seed: 43 }); + const mist = ctx.createLinearGradient(0, h * 0.6, 0, h); + mist.addColorStop(0, 'rgba(255,255,255,0)'); mist.addColorStop(1, 'rgba(230,240,250,0.55)'); + ctx.fillStyle = mist; + ctx.fillRect(0, h * 0.6, w, h * 0.4); + } }, + { id: 'night', name: 'Night sky', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#070b1f'], [0.7, '#1a2a63'], [1, '#34457f']], 0.9); + const r = rng(55); + for (let i = 0; i < 260; i += 1) { + ctx.fillStyle = `rgba(255,255,255,${0.25 + r() * 0.75})`; + const size = r() < 0.06 ? 2.4 : 1.2; + ctx.fillRect(r() * w, r() * h * 0.75, size, size); + } + glow(ctx, w * 0.78, h * 0.22, h * 0.3, 'rgba(255,248,214,0.55)'); + ctx.fillStyle = '#fff6d6'; + ctx.beginPath(); ctx.arc(w * 0.78, h * 0.22, h * 0.06, 0, Math.PI * 2); ctx.fill(); + ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.1, color: '#0c1230', seed: 56 }); + } }, + { id: 'field', name: 'Wheat field', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#f3b66b'], [0.6, '#fbe3a8'], [1, '#fff4d6']], 0.62); + glow(ctx, w * 0.25, h * 0.5, h * 0.5, 'rgba(255,244,200,0.9)'); + const g = ctx.createLinearGradient(0, h * 0.58, 0, h); + g.addColorStop(0, '#d9a441'); g.addColorStop(1, '#8c5f1a'); + ctx.fillStyle = g; + ctx.fillRect(0, h * 0.58, w, h * 0.42); + const r = rng(66); + for (let i = 0; i < 1400; i += 1) { + const y = h * 0.58 + r() * h * 0.42; + const len = 6 + ((y - h * 0.58) / h) * 70; + ctx.strokeStyle = `rgba(${200 + r() * 50},${150 + r() * 60},${50 + r() * 40},${0.5 + r() * 0.4})`; + ctx.lineWidth = 1 + ((y - h * 0.58) / h) * 2.2; + const x = r() * w; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + (r() - 0.5) * 8, y - len); ctx.stroke(); + } + } }, + { id: 'lake', name: 'Mountain lake', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#7d8fc9'], [0.55, '#f2b6b6'], [1, '#fde3c4']], 0.55); + glow(ctx, w * 0.62, h * 0.3, h * 0.35, 'rgba(255,240,205,0.85)'); + ridge(ctx, w, h, { base: h * 0.34, amp: h * 0.42, detail: 11, color: '#6d7fae', seed: 74 }); + ridge(ctx, w, h, { base: h * 0.46, amp: h * 0.3, detail: 10, color: '#465b8d', seed: 75 }); + const water = ctx.createLinearGradient(0, h * 0.52, 0, h); + water.addColorStop(0, '#8493c2'); water.addColorStop(1, '#27375f'); + ctx.fillStyle = water; + ctx.fillRect(0, h * 0.52, w, h * 0.48); + ctx.save(); // the mountains, upside down in the water + ctx.globalAlpha = 0.35; + ctx.translate(0, h * 1.04); ctx.scale(1, -0.6); + ridge(ctx, w, h * 0.6, { base: h * 0.34, amp: h * 0.42, detail: 11, color: '#2b3a66', seed: 74 }); + ctx.restore(); + ctx.fillStyle = water; ctx.globalAlpha = 0.25; ctx.fillRect(0, h * 0.52, w, h * 0.03); ctx.globalAlpha = 1; + const r = rng(73); + for (let i = 0; i < 60; i += 1) { ctx.fillStyle = `rgba(255,255,255,${0.05 + r() * 0.12})`; ctx.fillRect(r() * w, h * 0.55 + r() * h * 0.43, 20 + r() * 90, 1.5); } + } }, + { id: 'forest', name: 'Misty forest', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#9fc3c0'], [1, '#e8f1ea']], 0.8); + glow(ctx, w * 0.5, h * 0.3, h * 0.5, 'rgba(255,255,235,0.8)'); + firs(ctx, w, { base: h * 0.62, size: h * 0.34, color: '#8fb3a8', seed: 81, count: 26 }); + fog(ctx, w, h * 0.45, h * 0.3, 0.55); + firs(ctx, w, { base: h * 0.76, size: h * 0.5, color: '#5d8c80', seed: 82, count: 20 }); + fog(ctx, w, h * 0.58, h * 0.3, 0.45); + firs(ctx, w, { base: h * 0.95, size: h * 0.5, color: '#2f5b53', seed: 83, count: 10 }); + ctx.fillStyle = '#1e3f3a'; ctx.fillRect(0, h * 0.92, w, h * 0.08); + } }, + { id: 'desert', name: 'Desert dunes', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#e9a05e'], [0.55, '#f7cf98'], [1, '#fdecc8']], 0.6); + glow(ctx, w * 0.72, h * 0.36, h * 0.5, 'rgba(255,248,214,0.95)'); + ctx.fillStyle = '#fff3cf'; ctx.beginPath(); ctx.arc(w * 0.72, h * 0.36, h * 0.07, 0, Math.PI * 2); ctx.fill(); + ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.08, detail: 3, color: '#e0a566', seed: 91 }); + ridge(ctx, w, h, { base: h * 0.7, amp: h * 0.1, detail: 3, color: '#c9824a', seed: 92 }); + ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.09, detail: 3, color: '#a35f32', seed: 93 }); + ridge(ctx, w, h, { base: h * 0.95, amp: h * 0.05, detail: 2, color: '#7a4224', seed: 94 }); + } }, + { id: 'rays', name: 'Light through clouds', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#2c3e6e'], [0.5, '#6f86b5'], [1, '#c9d6ec']], 0.8); + const sx = w * 0.55; const sy = h * 0.12; + const r = rng(101); + for (let i = 0; i < 16; i += 1) { + const angle = Math.PI * (0.18 + r() * 0.64); + const spread = 0.025 + r() * 0.05; + const g = ctx.createLinearGradient(sx, sy, sx + Math.cos(angle) * h, sy + Math.sin(angle) * h); + g.addColorStop(0, `rgba(255,244,200,${0.3 + r() * 0.25})`); g.addColorStop(1, 'rgba(255,244,200,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.moveTo(sx, sy); + ctx.lineTo(sx + Math.cos(angle - spread) * h * 1.3, sy + Math.sin(angle - spread) * h * 1.3); + ctx.lineTo(sx + Math.cos(angle + spread) * h * 1.3, sy + Math.sin(angle + spread) * h * 1.3); + ctx.closePath(); ctx.fill(); + } + glow(ctx, sx, sy, h * 0.45, 'rgba(255,248,220,0.95)'); + cloud(ctx, w * 0.25, h * 0.28, h * 0.45, 0.8, '255,255,255', 102); + cloud(ctx, w * 0.8, h * 0.22, h * 0.4, 0.75, '245,248,255', 103); + cloud(ctx, w * 0.5, h * 0.12, h * 0.35, 0.7, '255,250,235', 104); + ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.09, color: '#1f3047', seed: 105 }); + } }, + { id: 'winter', name: 'Winter snow', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#9fb6d4'], [1, '#eef4fb']], 0.7); + glow(ctx, w * 0.2, h * 0.25, h * 0.4, 'rgba(255,255,255,0.8)'); + ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.14, detail: 6, color: '#cfdced', seed: 111 }); + firs(ctx, w, { base: h * 0.72, size: h * 0.26, color: '#6b8aa6', seed: 112, count: 16 }); + ridge(ctx, w, h, { base: h * 0.74, amp: h * 0.1, color: '#e9f1fa', seed: 113 }); + firs(ctx, w, { base: h * 0.9, size: h * 0.42, color: '#3d5e7c', seed: 114, count: 7 }); + ridge(ctx, w, h, { base: h * 0.9, amp: h * 0.06, color: '#ffffff', seed: 115 }); + const r = rng(116); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + for (let i = 0; i < 160; i += 1) { ctx.beginPath(); ctx.arc(r() * w, r() * h, 0.8 + r() * 2.2, 0, Math.PI * 2); ctx.fill(); } + } }, + { id: 'autumn', name: 'Autumn hills', paint(ctx, w, h) { + sky(ctx, w, h, [[0, '#f0b27a'], [0.6, '#fbe0b5'], [1, '#fff3da']], 0.65); + glow(ctx, w * 0.35, h * 0.35, h * 0.45, 'rgba(255,245,210,0.9)'); + ridge(ctx, w, h, { base: h * 0.55, amp: h * 0.1, color: '#d9a05b', seed: 121 }); + ridge(ctx, w, h, { base: h * 0.66, amp: h * 0.12, color: '#c4693a', seed: 122 }); + ridge(ctx, w, h, { base: h * 0.78, amp: h * 0.1, color: '#a14a2a', seed: 123 }); + ridge(ctx, w, h, { base: h * 0.9, amp: h * 0.08, color: '#6e2f1c', seed: 124 }); + const r = rng(125); + for (let i = 0; i < 200; i += 1) { + ctx.fillStyle = ['#f2b33d', '#d9542b', '#e8832e', '#b5381f'][Math.floor(r() * 4)]; + ctx.globalAlpha = 0.5 + r() * 0.4; + ctx.beginPath(); ctx.arc(r() * w, h * 0.62 + r() * h * 0.36, 2 + r() * 5, 0, Math.PI * 2); ctx.fill(); + } + ctx.globalAlpha = 1; + } }, +]; + +export const sceneById = (id) => SCENES.find((s) => s.id === id) ?? null; + +export function paintScene(ctx, id, w, h) { + (sceneById(id) ?? SCENES[0]).paint(ctx, w, h); +} + +// Draws a photo to fill the box without stretching it (cropping the overflow). +export function drawCover(ctx, img, w, h) { + const scale = Math.max(w / img.width, h / img.height); + const dw = img.width * scale; + const dh = img.height * scale; + ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh); +} + +const sceneUrls = new Map(); +// A small picture of a scene, as a data address for CSS backgrounds (made once per size). +export function sceneDataUrl(id, w = 900, h = 520) { + const key = `${id}/${w}x${h}`; + if (!sceneUrls.has(key)) { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + paintScene(ctx, id, w, h); + sceneUrls.set(key, canvas.toDataURL('image/jpeg', 0.85)); + } + return sceneUrls.get(key); +} + +export function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Could not load that picture.')); + img.src = src; + }); +} + +// Photos listed in /verse-backgrounds/index.json: [{ "file": "mountain.jpg", "name": "Mountain" }, ...] (or bare file names). +export async function loadPhotoList() { + try { + const res = await fetch('/verse-backgrounds/index.json', { cache: 'no-cache' }); + if (!res.ok) return []; + const list = await res.json(); + if (!Array.isArray(list)) return []; + return list + .map((item) => (typeof item === 'string' ? { file: item } : item)) + .filter((p) => p && /^[\w.-]+\.(jpe?g|png|webp)$/i.test(p.file ?? '')) + .map((p) => ({ id: p.file, name: p.name || p.file.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' '), url: `/verse-backgrounds/${p.file}` })); + } catch { return []; } +} + +// The choices a person can make: 'auto' (a different picture each day), 'plain', or a scene/photo id. +// -> { kind: 'scene', id } | { kind: 'photo', url, id } | null for plain. +export function resolveBackground(choice, photos = [], date = new Date()) { + if (choice === 'plain') return null; + const all = [...SCENES.map((s) => ({ kind: 'scene', id: s.id })), ...photos.map((p) => ({ kind: 'photo', id: p.id, url: p.url }))]; + if (!choice || choice === 'auto') { + const day = Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000); + return all[day % all.length]; + } + return all.find((b) => b.id === choice) ?? all[0]; +} diff --git a/src/utils/verseBackgrounds.test.js b/src/utils/verseBackgrounds.test.js new file mode 100644 index 0000000..a7cf924 --- /dev/null +++ b/src/utils/verseBackgrounds.test.js @@ -0,0 +1,43 @@ +import { test, expect, vi, afterEach } from 'vitest'; +import { SCENES, loadPhotoList, resolveBackground, sceneById } from './verseBackgrounds.js'; + +afterEach(() => vi.unstubAllGlobals()); + +test('there are several distinct built-in scenes', () => { + expect(SCENES.length).toBeGreaterThanOrEqual(5); + expect(new Set(SCENES.map((s) => s.id)).size).toBe(SCENES.length); + expect(sceneById('night').name).toBe('Night sky'); + expect(sceneById('nope')).toBeNull(); +}); + +test('"auto" is a different picture each day and the same all day', () => { + const mon = resolveBackground('auto', [], new Date(2026, 9, 5, 8)); + const monLater = resolveBackground('auto', [], new Date(2026, 9, 5, 22)); + const tue = resolveBackground('auto', [], new Date(2026, 9, 6, 8)); + expect(mon).toEqual(monLater); + expect(tue).not.toEqual(mon); + const days = new Set(Array.from({ length: SCENES.length }, (_, i) => resolveBackground('auto', [], new Date(2026, 9, 1 + i)).id)); + expect(days.size).toBe(SCENES.length); // every scene gets its day +}); + +test('choices resolve to plain, a scene or a photo', () => { + const photos = [{ id: 'lake.jpg', name: 'Lake', url: '/verse-backgrounds/lake.jpg' }]; + expect(resolveBackground('plain', photos)).toBeNull(); + expect(resolveBackground('sea', photos)).toEqual({ kind: 'scene', id: 'sea' }); + expect(resolveBackground('lake.jpg', photos)).toEqual({ kind: 'photo', id: 'lake.jpg', url: '/verse-backgrounds/lake.jpg' }); + expect(resolveBackground('gone.jpg', photos).kind).toBe('scene'); // a removed photo falls back to a scene + const withPhoto = new Set(Array.from({ length: SCENES.length + 1 }, (_, i) => resolveBackground('auto', photos, new Date(2026, 9, 1 + i)).id)); + expect(withPhoto.has('lake.jpg')).toBe(true); // photos join the daily rotation +}); + +test('your photos come from index.json; bad entries and missing lists are ignored', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ['wheat-field.jpg', { file: 'lake.png', name: 'Mountain lake' }, '../evil.jpg', 'notes.txt', null] }))); + expect(await loadPhotoList()).toEqual([ + { id: 'wheat-field.jpg', name: 'wheat field', url: '/verse-backgrounds/wheat-field.jpg' }, + { id: 'lake.png', name: 'Mountain lake', url: '/verse-backgrounds/lake.png' }, + ]); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false }))); + expect(await loadPhotoList()).toEqual([]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + expect(await loadPhotoList()).toEqual([]); +}); diff --git a/src/utils/verseCard.js b/src/utils/verseCard.js new file mode 100644 index 0000000..7a368d6 --- /dev/null +++ b/src/utils/verseCard.js @@ -0,0 +1,125 @@ +// A shareable picture of a verse: the words, the reference and the translation on a square card in one of +// the app's reading themes, drawn on a canvas so it works for every translation (including licensed ones, +// whose copyright line is printed on the card). No server involved. +import { READER_THEMES } from '../components/reader/readerThemes.js'; +import { drawCover, loadImage, paintScene } from './verseBackgrounds.js'; + +export const CARD_SIZE = 1080; +const FONT = 'Georgia, "Times New Roman", serif'; +const SANS = '-apple-system, "Segoe UI", Helvetica, Arial, sans-serif'; + +// Breaks `text` into lines no wider than maxWidth. `measure(string)` gives a string's width, so this is +// testable without a canvas. A single word wider than the line is left on its own line. +export function wrapLines(text, maxWidth, measure) { + const lines = []; + let line = ''; + for (const word of String(text).split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word; + if (line && measure(next) > maxWidth) { lines.push(line); line = word; } else line = next; + } + if (line) lines.push(line); + return lines; +} + +// The biggest font size (from maxSize down to minSize) at which the text fits the box; its lines too. +export function fitText(text, { maxWidth, maxHeight, maxSize = 76, minSize = 30, lineHeight = 1.38, measureAt }) { + for (let size = maxSize; size >= minSize; size -= 2) { + const lines = wrapLines(text, maxWidth, (s) => measureAt(size, s)); + if (lines.length * size * lineHeight <= maxHeight) return { size, lines, lineHeight }; + } + const lines = wrapLines(text, maxWidth, (s) => measureAt(minSize, s)); + return { size: minSize, lines, lineHeight }; +} + +const themeColors = (id) => { + const v = (READER_THEMES[id] ?? READER_THEMES.paper).vars; + return { bg: v['--r-bg'], text: v['--r-text'], sub: v['--r-sub'], accent: v['--r-accent'], line: v['--r-line'], bar: v['--r-bar'] }; +}; + +// -> Promise (PNG). `notice` is the translation's copyright line, shown small at the foot. +// `background` puts a picture behind the words: { kind: 'scene', id }, { kind: 'photo', url } or { kind: 'image', image }. +export async function renderVerseCard({ text, reference, translation, notice = '', themeId = 'paper', background = null }) { + const photo = background?.kind === 'photo' ? await loadImage(background.url) : background?.kind === 'image' ? background.image : null; + const canvas = document.createElement('canvas'); + canvas.width = CARD_SIZE; + canvas.height = CARD_SIZE; + const ctx = canvas.getContext('2d'); + if (!ctx) return Promise.reject(new Error('This browser can’t draw the image.')); + const pad = 96; + let c = themeColors(themeId); + + if (background) { + // a picture behind the words, darkened so they stay easy to read + if (photo) drawCover(ctx, photo, CARD_SIZE, CARD_SIZE); else paintScene(ctx, background.id, CARD_SIZE, CARD_SIZE); + const scrim = ctx.createLinearGradient(0, 0, 0, CARD_SIZE); + scrim.addColorStop(0, 'rgba(0,0,0,0.3)'); scrim.addColorStop(0.5, 'rgba(0,0,0,0.4)'); scrim.addColorStop(1, 'rgba(0,0,0,0.55)'); + ctx.fillStyle = scrim; + ctx.fillRect(0, 0, CARD_SIZE, CARD_SIZE); + c = { bg: '#000', text: '#ffffff', sub: 'rgba(255,255,255,0.86)', accent: '#ffe0b0', line: 'rgba(255,255,255,0.4)', bar: '#000' }; + ctx.shadowColor = 'rgba(0,0,0,0.55)'; + ctx.shadowBlur = 14; + } else { + ctx.fillStyle = c.bg; + ctx.fillRect(0, 0, CARD_SIZE, CARD_SIZE); + } + ctx.strokeStyle = c.line; + ctx.lineWidth = 3; + ctx.strokeRect(36, 36, CARD_SIZE - 72, CARD_SIZE - 72); + + // accent mark + ctx.fillStyle = c.accent; + ctx.fillRect(CARD_SIZE / 2 - 36, 112, 72, 6); + + // the verse, as large as will fit + const box = { top: 170, height: 560 }; + const fit = fitText(text, { + maxWidth: CARD_SIZE - pad * 2, maxHeight: box.height, + measureAt: (size, s) => { ctx.font = `${size}px ${FONT}`; return ctx.measureText(s).width; }, + }); + ctx.font = `${fit.size}px ${FONT}`; + ctx.fillStyle = c.text; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + const blockHeight = fit.lines.length * fit.size * fit.lineHeight; + let y = box.top + (box.height - blockHeight) / 2 + fit.size; + for (const line of fit.lines) { ctx.fillText(line, CARD_SIZE / 2, y); y += fit.size * fit.lineHeight; } + + // reference and translation + ctx.fillStyle = c.accent; + ctx.font = `bold 46px ${FONT}`; + ctx.fillText(reference, CARD_SIZE / 2, 800); + ctx.fillStyle = c.sub; + ctx.font = `28px ${SANS}`; + ctx.fillText(translation, CARD_SIZE / 2, 850); + + // the translation's copyright line, then the app's name + if (notice) { + ctx.font = `17px ${SANS}`; + wrapLines(notice, CARD_SIZE - pad * 2, (s) => ctx.measureText(s).width).slice(0, 3) + .forEach((l, i) => ctx.fillText(l, CARD_SIZE / 2, 910 + i * 24)); + } + ctx.fillStyle = c.sub; + ctx.font = `22px ${SANS}`; + ctx.fillText('Bible Study', CARD_SIZE / 2, 1010); + + return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not make the image.'))), 'image/png')); +} + +export const verseCardFileName = (reference) => `${reference.replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').toLowerCase()}.png`; + +// Shares the image with the device's share sheet when it can take files; otherwise saves it. -> 'shared' | 'saved' +export async function shareOrSaveImage(blob, fileName, { title = '', text = '' } = {}) { + const file = new File([blob], fileName, { type: 'image/png' }); + if (navigator.canShare?.({ files: [file] })) { + try { await navigator.share({ files: [file], title, text }); return 'shared'; } catch (err) { if (err?.name === 'AbortError') return 'shared'; /* otherwise fall through to saving */ } + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + return 'saved'; +}