Highlight translation differences, first-draft snapshot, verse notes, paste check
- Compare translations can highlight the words that differ from the first translation (toggle remembered). - A copy of your notes is kept the first time commentary is shown; "First draft, before commentary" under More shows what changed since. Saving it doesn't count as editing the chunk. - Notes on single verses: a pencil next to each verse in Study, and a note line under each verse in Compare. They merge per verse across devices. - Pasting 15+ words into a note asks "Is this from AI?"; "I wrote it" counts the words as yours again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a28ffa51a
commit
648c3206df
14 files changed
+232
-27
No files matched your search
@@ -5,8 +5,9 @@ import { useApp } from '../../context/AppContext.js';
|
||||
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
||||
import { alignVerses, translationSpans } from '../../utils/compareTranslations.js';
|
||||
import { ownShare, pastedWords, withPaste } from '../../utils/ownWork.js';
|
||||
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
|
||||
import { ownShare, pastedWords } from '../../utils/ownWork.js';
|
||||
import { usePasteCheck } from './PastePrompt.jsx';
|
||||
|
||||
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||
|
||||
@@ -48,6 +49,7 @@ const OIA = [
|
||||
// They are the same notes as on the Study page, and long pastes are tagged the same way.
|
||||
function OiaBoxes({ chunk, updateChunk }) {
|
||||
const share = ownShare(chunk);
|
||||
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||
return (
|
||||
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
|
||||
@@ -58,9 +60,10 @@ function OiaBoxes({ chunk, updateChunk }) {
|
||||
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
|
||||
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
|
||||
onPaste={(e) => { const next = withPaste(chunk, field, e.clipboardData?.getData('text')); if (next) updateChunk(chunk.id, { aiPastes: next }); }}
|
||||
onPaste={pasteCheck.onPaste(field)}
|
||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
|
||||
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
{pasteCheck.prompt(field)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -71,7 +74,9 @@ function OiaBoxes({ chunk, updateChunk }) {
|
||||
|
||||
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
|
||||
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
|
||||
function TranslationsView({ project, chunkEntry }) {
|
||||
function TranslationsView({ project, chunkEntry, updateChunk }) {
|
||||
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
|
||||
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
|
||||
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
|
||||
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
|
||||
const [picked, setPicked] = useState(() => {
|
||||
@@ -116,6 +121,9 @@ function TranslationsView({ project, chunkEntry }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{shown.length > 1 && (
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
|
||||
)}
|
||||
{picked.map((id) => loaded[id]?.status === 'error' && (
|
||||
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
||||
))}
|
||||
@@ -126,17 +134,27 @@ function TranslationsView({ project, chunkEntry }) {
|
||||
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
||||
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="grid gap-x-4 gap-y-1 border-t border-[var(--r-line)] pt-3 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||
{shown.map((id) => (
|
||||
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||
{row.texts[id] ?? <span className="text-[var(--r-sub)]">—</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{rows.map((row) => {
|
||||
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
|
||||
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
|
||||
return (
|
||||
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
|
||||
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||
{shown.map((id) => (
|
||||
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
|
||||
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
|
||||
onChange={(e) => updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
|
||||
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
||||
{shown.map((id) => {
|
||||
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
||||
@@ -187,7 +205,7 @@ export default function CompareSheet({ currentChunkId, onClose }) {
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
||||
<TranslationsView project={project} chunkEntry={mine} />
|
||||
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
|
||||
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// "What changed since my first draft": your Observe / Interpret / Apply notes now, with the words added or
|
||||
// reworded since the copy kept when you first opened commentary. It shows whether commentary changed your thinking.
|
||||
import { diffVerses } from '../../utils/textDiff.js';
|
||||
import { markRanges } from '../../utils/compareTranslations.js';
|
||||
|
||||
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||
const count = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export default function FirstDraft({ chunk }) {
|
||||
const draft = chunk.firstDraft;
|
||||
if (!draft) return null;
|
||||
const rows = FIELDS.map(([f, label]) => {
|
||||
const then = draft[f] ?? '';
|
||||
const now = chunk[f] ?? '';
|
||||
return { f, label, then, now, same: then.trim() === now.trim(), parts: markRanges(now, diffVerses(then, now).b) };
|
||||
}).filter((r) => r.then.trim() || r.now.trim());
|
||||
const changed = rows.filter((r) => !r.same).length;
|
||||
return (
|
||||
<details className="rounded-xl border border-[var(--r-line)] p-3 text-xs text-[var(--r-sub)]">
|
||||
<summary className="cursor-pointer">First draft, before commentary <span className="opacity-70">· {new Date(draft.at).toLocaleDateString()} · {changed === 0 ? 'nothing changed since' : `${changed} of ${rows.length} notes changed since`}</span></summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{rows.length === 0 && <p>You opened commentary before writing anything.</p>}
|
||||
{rows.map((r) => (
|
||||
<div key={r.f}>
|
||||
<p className="font-medium text-[var(--r-text)]">{r.label} <span className="font-normal text-[var(--r-sub)]">· {count(r.then)} words then, {count(r.now)} now</span></p>
|
||||
{r.same
|
||||
? <p className="mt-0.5">Unchanged.</p>
|
||||
: <p className="mt-0.5 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-text)]">{r.parts.map((p, i) => (p.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{p.text}</mark> : <span key={i}>{p.text}</span>))}</p>}
|
||||
{!r.same && r.then.trim() && <details className="mt-1"><summary className="cursor-pointer">Show the first draft</summary><p className="mt-1 whitespace-pre-line font-serif text-[13px] leading-relaxed">{r.then}</p></details>}
|
||||
</div>
|
||||
))}
|
||||
<p className="opacity-70">Highlighted words are new or reworded since the draft.</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
// area for background notes and tags so the page stays quiet.
|
||||
import { useEffect, useState } from 'react';
|
||||
import SessionNotes from './SessionNotes.jsx';
|
||||
import FirstDraft from './FirstDraft.jsx';
|
||||
import { usePasteCheck } from './PastePrompt.jsx';
|
||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
||||
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
|
||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||
|
||||
const SECTIONS = [
|
||||
@@ -39,10 +41,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||
|
||||
const share = ownShare(chunk);
|
||||
const keepPaste = (field) => (e) => {
|
||||
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
||||
if (next) updateChunk(chunk.id, { aiPastes: next });
|
||||
};
|
||||
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||
|
||||
return (
|
||||
<section aria-label="Study notes" className="space-y-2">
|
||||
@@ -74,7 +73,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||
onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
{pasteCheck.prompt(field)}
|
||||
{pasted > 0 && (
|
||||
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
||||
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
||||
@@ -110,6 +110,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||
</label>
|
||||
<FirstDraft chunk={chunk} />
|
||||
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||
{saveTemplate && (
|
||||
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// When a long block of text is pasted into a note, ask where it came from. Pastes are remembered as "not typed by
|
||||
// me" straight away (so forgetting to answer never counts as your own work); saying you wrote it takes that back.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { withPaste, withoutPaste, PASTE_MIN_WORDS } from '../../utils/ownWork.js';
|
||||
|
||||
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||
const wordsIn = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export function usePasteCheck(chunk, updateChunk) {
|
||||
const [pending, setPending] = useState(null); // { field, text }
|
||||
useEffect(() => { setPending(null); }, [chunk.id]);
|
||||
|
||||
const onPaste = (field) => (e) => {
|
||||
const text = e.clipboardData?.getData('text') ?? '';
|
||||
const next = withPaste(chunk, field, text);
|
||||
if (!next) return;
|
||||
updateChunk(chunk.id, { aiPastes: next });
|
||||
setPending({ field, text: text.trim() });
|
||||
};
|
||||
const wroteIt = () => {
|
||||
if (pending) updateChunk(chunk.id, { aiPastes: withoutPaste(chunk, pending.field, pending.text) });
|
||||
setPending(null);
|
||||
};
|
||||
const prompt = (field) => (pending && pending.field === field ? <PastePrompt field={field} words={wordsIn(pending.text)} onKeep={() => setPending(null)} onWroteIt={wroteIt} /> : null);
|
||||
return { onPaste, prompt };
|
||||
}
|
||||
|
||||
function PastePrompt({ field, words, onKeep, onWroteIt }) {
|
||||
const btn = 'rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5';
|
||||
return (
|
||||
<div role="group" aria-label="Where did this paste come from?" className="mt-1.5 rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-xs">
|
||||
<p className="font-medium text-[var(--r-text)]">You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?</p>
|
||||
<p className="mt-0.5 text-[var(--r-sub)]">Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={onKeep} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)]`}>Yes, from AI</button>
|
||||
<button type="button" onClick={onKeep} className={btn}>No, a book or website</button>
|
||||
<button type="button" onClick={onWroteIt} className={btn}>No, I wrote it</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Pastes under {PASTE_MIN_WORDS} words aren’t asked about.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,13 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
||||
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
||||
// The first time commentary or study aids are on screen for a chunk, keep a copy of your notes as they were,
|
||||
// so you can later see what changed after reading others (shown under "More" in the notes).
|
||||
const seeing = (tab === 'commentary' || tab === 'aids') && !held;
|
||||
useEffect(() => {
|
||||
if (!seeing || chunk.firstDraft) return;
|
||||
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState } from 'react';
|
||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||
const [openVerse, setOpenVerse] = useState(null);
|
||||
const [editingNote, setEditingNote] = useState(null);
|
||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||
return (
|
||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||
@@ -20,6 +21,17 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||
</button>
|
||||
<span>{v.text}</span>
|
||||
{onVerseNote && (
|
||||
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||
className={`ml-1.5 align-baseline font-sans text-[0.7em] ${verseNotes[`${v.chapter}:${v.number}`] ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)] opacity-40 hover:opacity-100'}`}>✎</button>
|
||||
)}
|
||||
{(verseNotes[`${v.chapter}:${v.number}`] || editingNote === `${v.chapter}:${v.number}`) && (
|
||||
<textarea aria-label={`Verse ${v.number} note`} value={verseNotes[`${v.chapter}:${v.number}`] ?? ''} rows={2} autoFocus={editingNote === `${v.chapter}:${v.number}`}
|
||||
onChange={(e) => onVerseNote?.(`${v.chapter}:${v.number}`, e.target.value)}
|
||||
placeholder="Your note on this verse"
|
||||
className="mt-1 block w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 font-sans text-[13px] leading-snug text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
)}
|
||||
{open && words && (
|
||||
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||
{words.map((w, i) => {
|
||||
|
||||
Reference in new issue
Block a user