Complete phase 10 notebook clipping workflow

This commit is contained in:
2026-07-15 19:37:34 -05:00
parent 7a56c7d205
commit 73314c494e
13 changed files with 498 additions and 29 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Completed Phase 10 notebook and clipping workflow with structured notebook blocks for verse/resource clips, links, pasted screenshots/images, and export metadata.
- Added a Parallel study-stack book that follows the pinned verse, compares it against an alternate translation, and shows alternate translation notes with previous/next context. - Added a Parallel study-stack book that follows the pinned verse, compares it against an alternate translation, and shows alternate translation notes with previous/next context.
- Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered. - Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered.
- Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order. - Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order.
+2 -1
View File
@@ -83,12 +83,13 @@ Goal: make the reader, study margin, notes, search, and resource layout feel lik
Current milestone: Current milestone:
- Phase 9: Bible desk layout and verse-anchored notes. Complete. - Phase 9: Bible desk layout and verse-anchored notes. Complete.
- Phase 10: Notebook and clipping workflow. Complete.
Near candidates: Near candidates:
- Improve search result navigation and saved searches. - Improve search result navigation and saved searches.
- Add cross-reference display once a LibreBible package exists. - Add cross-reference display once a LibreBible package exists.
- Improve note clipping, highlights, tags, and sermon/study workflows. - Improve highlights, tags, and sermon/study document workflows.
- Polish page layout, responsive behavior, and accessibility. - Polish page layout, responsive behavior, and accessibility.
### Resource Ecosystem ### Resource Ecosystem
+2 -2
View File
@@ -13,7 +13,7 @@ Current foundation:
- Session-aware resource toggles. - Session-aware resource toggles.
- Strong's links and definitions. - Strong's links and definitions.
- Verse-anchored margin notes with markers and export metadata. - Verse-anchored margin notes with markers and export metadata.
- Notebook book in the study stack. - Notebook book in the study stack with structured text/link/resource/image clip blocks.
- Timeline. - Timeline.
- Search. - Search.
- Maps and audio controls where resources exist. - Maps and audio controls where resources exist.
@@ -22,7 +22,7 @@ Good next milestones:
- Cross-reference UX after a LibreBible package exists. - Cross-reference UX after a LibreBible package exists.
- Better search result navigation and saved searches. - Better search result navigation and saved searches.
- Highlights, richer note clipping, and image/link notebook blocks. - Highlights, clip organization, and richer notebook editing.
- Sermon/study document workflows. - Sermon/study document workflows.
- Responsive layout and accessibility pass. - Responsive layout and accessibility pass.
+11
View File
@@ -26,6 +26,7 @@ Working HotView behavior includes:
- KJV audio controls from the packaged Eliran Wong source. - KJV audio controls from the packaged Eliran Wong source.
- Session-aware resource toggles. - Session-aware resource toggles.
- Notes, timeline, imports, exports, and one-way Nextcloud WebDAV export. - Notes, timeline, imports, exports, and one-way Nextcloud WebDAV export.
- Structured notebook clips for verses, study resources, links, and pasted/dropped images.
Architecture now includes: Architecture now includes:
@@ -97,3 +98,13 @@ Phase 9 started the Study Experience track by changing the workspace into a Bibl
- The middle margin now focuses on the hovered or clicked verse and keeps compact verse material there. - The middle margin now focuses on the hovered or clicked verse and keeps compact verse material there.
- Margin notes can be written directly against a verse, carry markers like `!` and `?`, and preserve book/chapter/verse plus translation context for export. - Margin notes can be written directly against a verse, carry markers like `!` and `?`, and preserve book/chapter/verse plus translation context for export.
- Session exports now include margin-note anchor metadata in JSON, Markdown, HTML, and CSV. - Session exports now include margin-note anchor metadata in JSON, Markdown, HTML, and CSV.
## Phase 10 Result
Phase 10 continued the Study Experience track by making the study-stack notebook a real clipping destination:
- Notebook notes now preserve structured blocks alongside the legacy draft text.
- Verses and study resources can carry clip metadata when dragged into the notebook.
- The notebook accepts dropped text, links, resource clips, image files, and pasted screenshots/images.
- Clips retain source type, title, timestamp, translation, and verse anchor when available.
- Session exports include notebook block data in JSON, Markdown, HTML, and CSV.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.36", "version": "0.1.37",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.36", "version": "0.1.37",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.36", "version": "0.1.37",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.36" version = "0.1.37"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.36" version = "0.1.37"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.36", "version": "0.1.37",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+131
View File
@@ -1367,6 +1367,118 @@ textarea:focus-visible {
font-size: 14px; font-size: 14px;
} }
.clip-current-verse {
justify-self: start;
border-color: #2f6f5e;
background: #eef6f1;
color: #1f5145;
}
.clip-drop-target {
display: grid;
place-items: center;
min-height: 78px;
padding: 14px;
border: 1px dashed #b8ae9b;
border-radius: 7px;
background: #fbf8ef;
color: #657061;
font-size: 13px;
font-weight: 800;
text-align: center;
}
.clip-drop-target:hover,
.clip-drop-target:focus-within {
border-color: #2f6f5e;
background: #eef6f1;
color: #1f5145;
}
.notebook-block-list {
display: grid;
gap: 10px;
}
.notebook-block {
display: grid;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid #e1d8c8;
border-left-width: 4px;
border-radius: 7px;
background: #fffdf6;
}
.notebook-block.clip {
border-left-color: #2f6f5e;
}
.notebook-block.link {
border-left-color: #5977a4;
}
.notebook-block.image {
border-left-color: #a05d49;
}
.notebook-block > header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.notebook-block > header span {
padding: 3px 6px;
border-radius: 999px;
background: #e8dfcf;
color: #657061;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
}
.notebook-block > header strong {
min-width: 0;
overflow: hidden;
color: #202124;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.notebook-block time,
.notebook-block small {
color: #657061;
font-size: 11px;
font-weight: 800;
}
.notebook-block p {
margin: 0;
color: #3f443d;
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.notebook-block a {
color: #365f91;
font-size: 12px;
font-weight: 800;
overflow-wrap: anywhere;
}
.notebook-block img {
max-width: 100%;
max-height: 320px;
border: 1px solid #e4dccd;
border-radius: 6px;
object-fit: contain;
}
.article-book article { .article-book article {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -2133,6 +2245,8 @@ textarea:focus-visible {
.theme-dark .original-language-verse, .theme-dark .original-language-verse,
.theme-dark .english-strong-line, .theme-dark .english-strong-line,
.theme-dark .resource-card, .theme-dark .resource-card,
.theme-dark .clip-drop-target,
.theme-dark .notebook-block,
.theme-dark .margin-order-list div, .theme-dark .margin-order-list div,
.theme-dark .parallel-current-verse, .theme-dark .parallel-current-verse,
.theme-dark .parallel-context section, .theme-dark .parallel-context section,
@@ -2145,6 +2259,19 @@ textarea:focus-visible {
background: #20251f; background: #20251f;
} }
.theme-dark .clip-current-verse,
.theme-dark .clip-drop-target:hover,
.theme-dark .clip-drop-target:focus-within {
border-color: #527b68;
background: #203329;
color: #dfe8d8;
}
.theme-dark .notebook-block > header span {
background: #343b31;
color: #aaa996;
}
.theme-dark .context-map { .theme-dark .context-map {
border-color: #3f493f; border-color: #3f493f;
background: background:
@@ -2205,6 +2332,8 @@ textarea:focus-visible {
.theme-dark .parallel-controls label, .theme-dark .parallel-controls label,
.theme-dark .parallel-context strong, .theme-dark .parallel-context strong,
.theme-dark .parallel-note-sections h3, .theme-dark .parallel-note-sections h3,
.theme-dark .notebook-block time,
.theme-dark .notebook-block small,
.theme-dark .session-facts dt, .theme-dark .session-facts dt,
.theme-dark .about-list dt, .theme-dark .about-list dt,
.theme-dark .modal-section label, .theme-dark .modal-section label,
@@ -2232,6 +2361,8 @@ textarea:focus-visible {
.theme-dark .parallel-current-verse p:last-child, .theme-dark .parallel-current-verse p:last-child,
.theme-dark .parallel-context p, .theme-dark .parallel-context p,
.theme-dark .parallel-note-sections p, .theme-dark .parallel-note-sections p,
.theme-dark .notebook-block p,
.theme-dark .notebook-block > header strong,
.theme-dark .parallel-controls output { .theme-dark .parallel-controls output {
color: #eee8d8 !important; color: #eee8d8 !important;
} }
+236 -15
View File
@@ -46,6 +46,7 @@ import type {
StrongDisplayMode, StrongDisplayMode,
HostedUser, HostedUser,
MarginPanelId, MarginPanelId,
NotebookBlock,
SessionNote, SessionNote,
StudyBook, StudyBook,
StudySession, StudySession,
@@ -62,6 +63,15 @@ const pageSize = 34
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1' const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
type PageMotion = 'idle' | 'turn-forward' | 'turn-back' type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
type DeskResizeTarget = 'bible-margin' | 'margin-stack' type DeskResizeTarget = 'bible-margin' | 'margin-stack'
type ClipPayload = {
text: string
title?: string
url?: string
type?: NotebookBlock['type']
sourceKind?: NonNullable<NotebookBlock['source']>['kind']
dataUrl?: string
mimeType?: string
}
type WindowedItems<T> = { type WindowedItems<T> = {
items: T[] items: T[]
start: number start: number
@@ -131,6 +141,10 @@ function verseAnchor(verse: Verse, translationId: string, endVerse = verse.verse
} }
} }
function isLikelyUrl(value: string) {
return /^https?:\/\/\S+$/i.test(value.trim())
}
function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) { function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) {
let changed = false let changed = false
const next = verses.map((verse) => { const next = verses.map((verse) => {
@@ -1356,6 +1370,7 @@ function App() {
updatedAt: now, updatedAt: now,
reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference, reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference,
translationId: activeSession.baseTranslation, translationId: activeSession.baseTranslation,
blocks: existingDraft?.blocks ?? [],
} }
patchActiveSession({ patchActiveSession({
noteDraft: value, noteDraft: value,
@@ -1363,6 +1378,28 @@ function App() {
}) })
} }
function upsertDraftNote(patch: Partial<SessionNote>) {
if (!activeSession) return
const now = new Date().toISOString()
const existingDraft = activeSession.notes.find((note) => note.kind === 'draft')
const draftNote: SessionNote = {
id: existingDraft?.id ?? timelineId(),
kind: 'draft',
body: patch.body ?? existingDraft?.body ?? noteDraft,
createdAt: existingDraft?.createdAt ?? now,
updatedAt: now,
reference: patch.reference ?? existingDraft?.reference ?? (pinnedVerse ? reference(pinnedVerse) : undefined),
translationId: patch.translationId ?? existingDraft?.translationId ?? activeSession.baseTranslation,
blocks: patch.blocks ?? existingDraft?.blocks ?? [],
}
patchActiveSession({
noteDraft: draftNote.body,
notes: [draftNote, ...activeSession.notes.filter((note) => note.id !== draftNote.id)],
})
setNoteDraft(draftNote.body)
}
function updateMarginNote(value: string) { function updateMarginNote(value: string) {
if (!activeSession || !selectedAnchor) return if (!activeSession || !selectedAnchor) return
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -1432,19 +1469,105 @@ function App() {
}) })
} }
function appendNoteClip(value: string) { function appendNotebookBlock(payload: ClipPayload) {
const clip = value.trim() if (!activeSession) return
const clip = payload.text.trim()
if (!clip) return if (!clip) return
const now = new Date().toISOString()
const existingDraft = activeSession.notes.find((note) => note.kind === 'draft')
const anchor = pinnedVerse ? verseAnchor(pinnedVerse, activeSession.baseTranslation) : undefined
const block: NotebookBlock = {
id: timelineId(),
type: payload.type ?? (payload.dataUrl ? 'image' : payload.url || isLikelyUrl(clip) ? 'link' : 'clip'),
body: clip,
title: payload.title,
url: payload.url ?? (isLikelyUrl(clip) ? clip : undefined),
dataUrl: payload.dataUrl,
mimeType: payload.mimeType,
createdAt: now,
updatedAt: now,
reference: anchor?.reference,
translationId: activeSession.baseTranslation,
anchor,
source: {
label: payload.title ?? anchor?.reference ?? 'Notebook clip',
kind: payload.sourceKind ?? (payload.dataUrl ? 'image' : payload.url || isLikelyUrl(clip) ? 'web' : 'resource'),
},
}
const nextBlocks = [block, ...(existingDraft?.blocks ?? [])]
const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip
updateNoteDraft(nextValue) upsertDraftNote({
body: nextValue,
blocks: nextBlocks,
reference: anchor?.reference,
translationId: activeSession.baseTranslation,
})
setActiveStudyBook('notebook') setActiveStudyBook('notebook')
} }
function startTextDrag(event: DragEvent<HTMLElement>, value: string) { function appendNoteClip(value: string) {
appendNotebookBlock({ text: value })
}
async function appendDroppedFiles(files: FileList | null) {
if (!files?.length) return false
let handled = false
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
const dataUrl = await readFileAsDataUrl(file)
appendNotebookBlock({
text: file.name,
title: file.name,
type: 'image',
sourceKind: 'image',
dataUrl,
mimeType: file.type,
})
handled = true
}
return handled
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error ?? new Error(`Unable to read ${file.name}.`))
reader.readAsDataURL(file)
})
}
function startTextDrag(event: DragEvent<HTMLElement>, value: string, payload?: Partial<ClipPayload>) {
event.dataTransfer.setData('text/plain', value) event.dataTransfer.setData('text/plain', value)
event.dataTransfer.setData(
'application/x-libre-study-clip',
JSON.stringify({
text: value,
...payload,
}),
)
event.dataTransfer.effectAllowed = 'copy' event.dataTransfer.effectAllowed = 'copy'
} }
function clipPayloadFromDrop(event: DragEvent<HTMLElement>): ClipPayload {
const raw = event.dataTransfer.getData('application/x-libre-study-clip')
if (raw) {
try {
const parsed = JSON.parse(raw) as Partial<ClipPayload>
if (parsed.text) return { text: parsed.text, ...parsed }
} catch {
// Fall back to plain text.
}
}
const uri = event.dataTransfer.getData('text/uri-list')
const text = event.dataTransfer.getData('text/plain')
return {
text: text || uri,
url: uri || (isLikelyUrl(text) ? text : undefined),
sourceKind: uri || isLikelyUrl(text) ? 'web' : 'resource',
}
}
function scheduleNotesDragOpen() { function scheduleNotesDragOpen() {
if (studyStackPinned || notesDragOpenTimeout.current) return if (studyStackPinned || notesDragOpenTimeout.current) return
notesDragOpenTimeout.current = window.setTimeout(() => { notesDragOpenTimeout.current = window.setTimeout(() => {
@@ -1941,7 +2064,12 @@ function App() {
<p <p
key={note.id} key={note.id}
draggable draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)} onDragStart={(event) =>
startTextDrag(event, `${note.reference} ${note.text}`, {
title: note.reference,
sourceKind: 'resource',
})
}
> >
<strong>{note.reference}</strong> <strong>{note.reference}</strong>
<span>{note.text}</span> <span>{note.text}</span>
@@ -2091,6 +2219,8 @@ function App() {
function renderNotebookBook() { function renderNotebookBook() {
if (!selectedVerse) return null if (!selectedVerse) return null
const draftNote = activeSession?.notes.find((note) => note.kind === 'draft')
const notebookBlocks = draftNote?.blocks ?? []
return ( return (
<section className="stack-book notebook-book" aria-label="Notebook"> <section className="stack-book notebook-book" aria-label="Notebook">
@@ -2101,16 +2231,78 @@ function App() {
</div> </div>
<span>{activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes</span> <span>{activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes</span>
</header> </header>
<button
type="button"
className="clip-current-verse"
onClick={() => {
if (!pinnedVerse) return
appendNotebookBlock({
text: `${reference(pinnedVerse)} ${pinnedVerse.text}`,
title: reference(pinnedVerse),
type: 'clip',
sourceKind: 'verse',
})
}}
>
Clip current verse
</button>
<textarea <textarea
value={noteDraft} value={noteDraft}
onChange={(event) => updateNoteDraft(event.target.value)} onChange={(event) => updateNoteDraft(event.target.value)}
onPaste={(event) => {
const imageFile = Array.from(event.clipboardData.files).find((file) => file.type.startsWith('image/'))
if (!imageFile) return
event.preventDefault()
appendDroppedFiles(event.clipboardData.files).catch((reason) => setError(String(reason)))
}}
onDrop={(event) => { onDrop={(event) => {
event.preventDefault() event.preventDefault()
appendNoteClip(event.dataTransfer.getData('text/plain')) appendDroppedFiles(event.dataTransfer.files)
.then((handled) => {
if (!handled) appendNotebookBlock(clipPayloadFromDrop(event))
})
.catch((reason) => setError(String(reason)))
}} }}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
placeholder="Write sermon notes, study observations, paste links, or drop clippings here." placeholder="Write sermon notes, study observations, paste links, or drop clippings here."
/> />
<div
className="clip-drop-target"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
appendDroppedFiles(event.dataTransfer.files)
.then((handled) => {
if (!handled) appendNotebookBlock(clipPayloadFromDrop(event))
})
.catch((reason) => setError(String(reason)))
}}
>
Drop text, links, verses, notes, or images here.
</div>
{notebookBlocks.length ? (
<div className="notebook-block-list" aria-label="Notebook clips">
{notebookBlocks.map((block) => (
<article key={block.id} className={`notebook-block ${block.type}`}>
<header>
<span>{block.type}</span>
<strong>{block.title || block.reference || block.source?.label || 'Notebook clip'}</strong>
<time>{new Date(block.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</time>
</header>
{block.dataUrl ? (
<img src={block.dataUrl} alt={block.title || block.body || 'Notebook image'} />
) : null}
{block.url ? (
<a href={block.url}>{block.url}</a>
) : null}
<p>{block.body}</p>
{block.reference ? <small>{block.reference}</small> : null}
</article>
))}
</div>
) : (
<p className="resource-disabled">No structured clips yet.</p>
)}
<div className="tag-editor"> <div className="tag-editor">
<input <input
value={tagInput} value={tagInput}
@@ -2219,6 +2411,10 @@ function App() {
`${entry.term}\n${entry.definitions `${entry.term}\n${entry.definitions
.map((definition) => `${definition.sourceName}: ${definition.text}`) .map((definition) => `${definition.sourceName}: ${definition.text}`)
.join('\n\n')}`, .join('\n\n')}`,
{
title: entry.term,
sourceKind: 'resource',
},
) )
} }
> >
@@ -2257,7 +2453,12 @@ function App() {
<article <article
key={entry.id} key={entry.id}
draggable draggable
onDragStart={(event) => startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`)} onDragStart={(event) =>
startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`, {
title: entry.reference,
sourceKind: 'resource',
})
}
> >
<p className="article-kicker">{entry.source}</p> <p className="article-kicker">{entry.source}</p>
<h3>{entry.title || entry.reference}</h3> <h3>{entry.title || entry.reference}</h3>
@@ -2461,7 +2662,12 @@ function App() {
<p <p
key={note.id} key={note.id}
draggable draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)} onDragStart={(event) =>
startTextDrag(event, `${note.reference} ${note.text}`, {
title: note.reference,
sourceKind: 'resource',
})
}
> >
<strong>{note.reference}</strong> <strong>{note.reference}</strong>
<span>{note.text}</span> <span>{note.text}</span>
@@ -2478,7 +2684,12 @@ function App() {
<p <p
key={note.id} key={note.id}
draggable draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)} onDragStart={(event) =>
startTextDrag(event, `${note.reference} ${note.text}`, {
title: note.reference,
sourceKind: 'resource',
})
}
> >
<strong>{note.reference}</strong> <strong>{note.reference}</strong>
<span>{note.text}</span> <span>{note.text}</span>
@@ -2698,7 +2909,12 @@ function App() {
draggable draggable
onMouseEnter={() => setHoveredId(verse.id)} onMouseEnter={() => setHoveredId(verse.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
onDragStart={(event) => startTextDrag(event, `${reference(verse)} ${verse.text}`)} onDragStart={(event) =>
startTextDrag(event, `${reference(verse)} ${verse.text}`, {
title: reference(verse),
sourceKind: 'verse',
})
}
onClick={() => { onClick={() => {
setHoveredId(null) setHoveredId(null)
selectVerse(verse) selectVerse(verse)
@@ -2735,7 +2951,7 @@ function App() {
aria-label="Show previous chapters" aria-label="Show previous chapters"
onClick={() => setChapterTabOffset((current) => current - Math.max(1, chapterTabCapacity.visibleCount - 2))} onClick={() => setChapterTabOffset((current) => current - Math.max(1, chapterTabCapacity.visibleCount - 2))}
> >
‹ &lt;
</button> </button>
{visibleChapterTabs.items.map((chapter) => ( {visibleChapterTabs.items.map((chapter) => (
<button <button
@@ -2754,7 +2970,7 @@ function App() {
aria-label="Show next chapters" aria-label="Show next chapters"
onClick={() => setChapterTabOffset((current) => current + Math.max(1, chapterTabCapacity.visibleCount - 2))} onClick={() => setChapterTabOffset((current) => current + Math.max(1, chapterTabCapacity.visibleCount - 2))}
> >
› &gt;
</button> </button>
</div> </div>
</div> </div>
@@ -2866,7 +3082,12 @@ function App() {
tabIndex={0} tabIndex={0}
onClick={() => recordFootnote(note.reference, note.text)} onClick={() => recordFootnote(note.reference, note.text)}
draggable draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)} onDragStart={(event) =>
startTextDrag(event, `${note.reference} ${note.text}`, {
title: note.reference,
sourceKind: 'resource',
})
}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault() event.preventDefault()
@@ -2898,7 +3119,7 @@ function App() {
aria-label="Show previous books" aria-label="Show previous books"
onClick={() => setBookTabOffset((current) => current - Math.max(1, bookTabCapacity.visibleCount - 2))} onClick={() => setBookTabOffset((current) => current - Math.max(1, bookTabCapacity.visibleCount - 2))}
> >
‹ &lt;
</button> </button>
{visibleBookTabs.items.map((book) => ( {visibleBookTabs.items.map((book) => (
<button <button
@@ -2918,7 +3139,7 @@ function App() {
aria-label="Show next books" aria-label="Show next books"
onClick={() => setBookTabOffset((current) => current + Math.max(1, bookTabCapacity.visibleCount - 2))} onClick={() => setBookTabOffset((current) => current + Math.max(1, bookTabCapacity.visibleCount - 2))}
> >
› &gt;
</button> </button>
</nav> </nav>
) )
+66 -5
View File
@@ -1,5 +1,5 @@
import { normalizeSessions, timelineId, workspaceSchemaVersion } from './workspace' import { normalizeSessions, timelineId, workspaceSchemaVersion } from './workspace'
import type { SessionNote, StudySession } from './workspace' import type { NotebookBlock, SessionNote, StudySession } from './workspace'
export type ExportFormat = 'json' | 'markdown' | 'html' | 'csv' export type ExportFormat = 'json' | 'markdown' | 'html' | 'csv'
@@ -126,7 +126,7 @@ function renderMarkdown(exportData: SessionExport) {
'## Notes', '## Notes',
'', '',
notes.length notes.length
? notes.map((note) => [`### ${noteHeading(note)}`, '', note.body.trim() || markerSummary(note)].join('\n')).join('\n\n---\n\n') ? notes.map(renderMarkdownNote).join('\n\n---\n\n')
: '_No notes recorded._', : '_No notes recorded._',
'', '',
'## Timeline', '## Timeline',
@@ -154,7 +154,7 @@ function renderHtml(exportData: SessionExport) {
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd> <dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
</dl> </dl>
<h2>Notes</h2> <h2>Notes</h2>
${notes.length ? notes.map((note) => `<article><h3>${escapeHtml(noteHeading(note))}</h3><pre>${escapeHtml(note.body.trim() || markerSummary(note))}</pre></article>`).join('\n') : '<p>No notes recorded.</p>'} ${notes.length ? notes.map(renderHtmlNote).join('\n') : '<p>No notes recorded.</p>'}
<h2>Timeline</h2> <h2>Timeline</h2>
${session.timeline.length ? `<ol>${session.timeline.map((entry) => `<li><time>${escapeHtml(entry.at)}</time> ${escapeHtml(entry.label)}${entry.reference ? ` (${escapeHtml(entry.reference)})` : ''}${entry.detail ? `: ${escapeHtml(entry.detail)}` : ''}</li>`).join('')}</ol>` : '<p>No timeline events recorded.</p>'} ${session.timeline.length ? `<ol>${session.timeline.map((entry) => `<li><time>${escapeHtml(entry.at)}</time> ${escapeHtml(entry.label)}${entry.reference ? ` (${escapeHtml(entry.reference)})` : ''}${entry.detail ? `: ${escapeHtml(entry.detail)}` : ''}</li>`).join('')}</ol>` : '<p>No timeline events recorded.</p>'}
</body> </body>
@@ -163,7 +163,7 @@ function renderHtml(exportData: SessionExport) {
function renderCsv(exportData: SessionExport) { function renderCsv(exportData: SessionExport) {
const rows = [ const rows = [
['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'book', 'chapter', 'start_verse', 'end_verse', 'markers', 'body'], ['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'book', 'chapter', 'start_verse', 'end_verse', 'markers', 'block_count', 'blocks_json', 'body'],
...exportData.notes.map((note) => [ ...exportData.notes.map((note) => [
exportData.session.title, exportData.session.title,
note.id, note.id,
@@ -177,12 +177,73 @@ function renderCsv(exportData: SessionExport) {
note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '', note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '',
note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '', note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '',
note.markers?.join(' ') ?? '', note.markers?.join(' ') ?? '',
String(note.blocks?.length ?? 0),
note.blocks?.length ? JSON.stringify(note.blocks) : '',
note.body, note.body,
]), ]),
] ]
return rows.map((row) => row.map(csvCell).join(',')).join('\n') return rows.map((row) => row.map(csvCell).join(',')).join('\n')
} }
function renderMarkdownNote(note: SessionNote) {
const sections = [`### ${noteHeading(note)}`, '']
const body = note.body.trim()
if (body) {
sections.push(body, '')
} else if (markerSummary(note)) {
sections.push(markerSummary(note), '')
}
if (note.blocks?.length) {
sections.push('#### Notebook Blocks', '')
sections.push(note.blocks.map(renderMarkdownBlock).join('\n\n'))
}
return sections.join('\n').trim()
}
function renderMarkdownBlock(block: NotebookBlock) {
const lines = [
`- Type: ${block.type}`,
block.title ? `- Title: ${block.title}` : '',
block.reference ? `- Reference: ${block.reference}` : '',
block.translationId ? `- Translation: ${block.translationId}` : '',
block.url ? `- URL: ${block.url}` : '',
`- Created: ${block.createdAt}`,
'',
block.dataUrl ? `![${block.title || block.body || 'Notebook image'}](${block.dataUrl})` : '',
block.body,
].filter(Boolean)
return lines.join('\n')
}
function renderHtmlNote(note: SessionNote) {
const body = note.body.trim() || markerSummary(note)
const blocks = note.blocks?.length
? `<section><h4>Notebook Blocks</h4>${note.blocks.map(renderHtmlBlock).join('\n')}</section>`
: ''
return `<article><h3>${escapeHtml(noteHeading(note))}</h3>${body ? `<pre>${escapeHtml(body)}</pre>` : ''}${blocks}</article>`
}
function renderHtmlBlock(block: NotebookBlock) {
const image = block.dataUrl
? `<img src="${escapeHtml(block.dataUrl)}" alt="${escapeHtml(block.title || block.body || 'Notebook image')}">`
: ''
const link = block.url ? `<p><a href="${escapeHtml(block.url)}">${escapeHtml(block.url)}</a></p>` : ''
return `<article>
<h5>${escapeHtml(block.title || block.reference || block.source?.label || block.type)}</h5>
<dl>
<dt>Type</dt><dd>${escapeHtml(block.type)}</dd>
${block.reference ? `<dt>Reference</dt><dd>${escapeHtml(block.reference)}</dd>` : ''}
${block.translationId ? `<dt>Translation</dt><dd>${escapeHtml(block.translationId)}</dd>` : ''}
<dt>Created</dt><dd>${escapeHtml(block.createdAt)}</dd>
</dl>
${image}
${link}
<p>${escapeHtml(block.body)}</p>
</article>`
}
function noteHeading(note: SessionNote) { function noteHeading(note: SessionNote) {
const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook' const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook'
const reference = note.anchor?.reference ?? note.reference const reference = note.anchor?.reference ?? note.reference
@@ -196,7 +257,7 @@ function markerSummary(note: SessionNote) {
} }
function hasExportableNoteContent(note: SessionNote) { function hasExportableNoteContent(note: SessionNote) {
return Boolean(note.body.trim() || note.markers?.length) return Boolean(note.body.trim() || note.markers?.length || note.blocks?.length)
} }
function csvCell(value: string) { function csvCell(value: string) {
+43
View File
@@ -42,6 +42,28 @@ export type SessionNote = {
translationId?: string translationId?: string
anchor?: VerseAnchor anchor?: VerseAnchor
markers?: string[] markers?: string[]
blocks?: NotebookBlock[]
}
export type NotebookBlockType = 'paragraph' | 'clip' | 'link' | 'image'
export type NotebookBlock = {
id: string
type: NotebookBlockType
body: string
createdAt: string
updatedAt: string
title?: string
url?: string
mimeType?: string
dataUrl?: string
reference?: string
translationId?: string
anchor?: VerseAnchor
source?: {
label: string
kind: 'verse' | 'resource' | 'web' | 'image' | 'manual'
}
} }
export type StudySession = { export type StudySession = {
@@ -257,6 +279,7 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
translationId: note.translationId ?? session.baseTranslation, translationId: note.translationId ?? session.baseTranslation,
anchor: note.anchor, anchor: note.anchor,
markers: Array.isArray(note.markers) ? note.markers : [], markers: Array.isArray(note.markers) ? note.markers : [],
blocks: normalizeNotebookBlocks(note.blocks),
})) }))
} }
@@ -270,10 +293,30 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
createdAt: session.createdAt ?? now, createdAt: session.createdAt ?? now,
updatedAt: now, updatedAt: now,
translationId: session.baseTranslation, translationId: session.baseTranslation,
blocks: [],
}, },
] ]
} }
function normalizeNotebookBlocks(blocks?: NotebookBlock[] | null): NotebookBlock[] {
if (!Array.isArray(blocks)) return []
return blocks.map((block) => ({
id: block.id || timelineId(),
type: ['paragraph', 'clip', 'link', 'image'].includes(block.type) ? block.type : 'clip',
body: block.body ?? '',
createdAt: block.createdAt ?? new Date().toISOString(),
updatedAt: block.updatedAt ?? block.createdAt ?? new Date().toISOString(),
title: block.title,
url: block.url,
mimeType: block.mimeType,
dataUrl: block.dataUrl,
reference: block.reference,
translationId: block.translationId,
anchor: block.anchor,
source: block.source,
}))
}
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string { export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
if (!sessions.length) return '' if (!sessions.length) return ''
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id