Complete phase 10 notebook clipping workflow
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
+2
-1
@@ -83,12 +83,13 @@ Goal: make the reader, study margin, notes, search, and resource layout feel lik
|
||||
Current milestone:
|
||||
|
||||
- Phase 9: Bible desk layout and verse-anchored notes. Complete.
|
||||
- Phase 10: Notebook and clipping workflow. Complete.
|
||||
|
||||
Near candidates:
|
||||
|
||||
- Improve search result navigation and saved searches.
|
||||
- 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.
|
||||
|
||||
### Resource Ecosystem
|
||||
|
||||
@@ -13,7 +13,7 @@ Current foundation:
|
||||
- Session-aware resource toggles.
|
||||
- Strong's links and definitions.
|
||||
- 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.
|
||||
- Search.
|
||||
- Maps and audio controls where resources exist.
|
||||
@@ -22,7 +22,7 @@ Good next milestones:
|
||||
|
||||
- Cross-reference UX after a LibreBible package exists.
|
||||
- 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.
|
||||
- Responsive layout and accessibility pass.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Working HotView behavior includes:
|
||||
- KJV audio controls from the packaged Eliran Wong source.
|
||||
- Session-aware resource toggles.
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "libre-study",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"private": true,
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Libre Study",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"identifier": "org.librestudy.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+131
@@ -1367,6 +1367,118 @@ textarea:focus-visible {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -2133,6 +2245,8 @@ textarea:focus-visible {
|
||||
.theme-dark .original-language-verse,
|
||||
.theme-dark .english-strong-line,
|
||||
.theme-dark .resource-card,
|
||||
.theme-dark .clip-drop-target,
|
||||
.theme-dark .notebook-block,
|
||||
.theme-dark .margin-order-list div,
|
||||
.theme-dark .parallel-current-verse,
|
||||
.theme-dark .parallel-context section,
|
||||
@@ -2145,6 +2259,19 @@ textarea:focus-visible {
|
||||
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 {
|
||||
border-color: #3f493f;
|
||||
background:
|
||||
@@ -2205,6 +2332,8 @@ textarea:focus-visible {
|
||||
.theme-dark .parallel-controls label,
|
||||
.theme-dark .parallel-context strong,
|
||||
.theme-dark .parallel-note-sections h3,
|
||||
.theme-dark .notebook-block time,
|
||||
.theme-dark .notebook-block small,
|
||||
.theme-dark .session-facts dt,
|
||||
.theme-dark .about-list dt,
|
||||
.theme-dark .modal-section label,
|
||||
@@ -2232,6 +2361,8 @@ textarea:focus-visible {
|
||||
.theme-dark .parallel-current-verse p:last-child,
|
||||
.theme-dark .parallel-context p,
|
||||
.theme-dark .parallel-note-sections p,
|
||||
.theme-dark .notebook-block p,
|
||||
.theme-dark .notebook-block > header strong,
|
||||
.theme-dark .parallel-controls output {
|
||||
color: #eee8d8 !important;
|
||||
}
|
||||
|
||||
+236
-15
@@ -46,6 +46,7 @@ import type {
|
||||
StrongDisplayMode,
|
||||
HostedUser,
|
||||
MarginPanelId,
|
||||
NotebookBlock,
|
||||
SessionNote,
|
||||
StudyBook,
|
||||
StudySession,
|
||||
@@ -62,6 +63,15 @@ const pageSize = 34
|
||||
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
|
||||
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||
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> = {
|
||||
items: T[]
|
||||
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) {
|
||||
let changed = false
|
||||
const next = verses.map((verse) => {
|
||||
@@ -1356,6 +1370,7 @@ function App() {
|
||||
updatedAt: now,
|
||||
reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference,
|
||||
translationId: activeSession.baseTranslation,
|
||||
blocks: existingDraft?.blocks ?? [],
|
||||
}
|
||||
patchActiveSession({
|
||||
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) {
|
||||
if (!activeSession || !selectedAnchor) return
|
||||
const now = new Date().toISOString()
|
||||
@@ -1432,19 +1469,105 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
function appendNoteClip(value: string) {
|
||||
const clip = value.trim()
|
||||
function appendNotebookBlock(payload: ClipPayload) {
|
||||
if (!activeSession) return
|
||||
const clip = payload.text.trim()
|
||||
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
|
||||
updateNoteDraft(nextValue)
|
||||
upsertDraftNote({
|
||||
body: nextValue,
|
||||
blocks: nextBlocks,
|
||||
reference: anchor?.reference,
|
||||
translationId: activeSession.baseTranslation,
|
||||
})
|
||||
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(
|
||||
'application/x-libre-study-clip',
|
||||
JSON.stringify({
|
||||
text: value,
|
||||
...payload,
|
||||
}),
|
||||
)
|
||||
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() {
|
||||
if (studyStackPinned || notesDragOpenTimeout.current) return
|
||||
notesDragOpenTimeout.current = window.setTimeout(() => {
|
||||
@@ -1941,7 +2064,12 @@ function App() {
|
||||
<p
|
||||
key={note.id}
|
||||
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>
|
||||
<span>{note.text}</span>
|
||||
@@ -2091,6 +2219,8 @@ function App() {
|
||||
|
||||
function renderNotebookBook() {
|
||||
if (!selectedVerse) return null
|
||||
const draftNote = activeSession?.notes.find((note) => note.kind === 'draft')
|
||||
const notebookBlocks = draftNote?.blocks ?? []
|
||||
|
||||
return (
|
||||
<section className="stack-book notebook-book" aria-label="Notebook">
|
||||
@@ -2101,16 +2231,78 @@ function App() {
|
||||
</div>
|
||||
<span>{activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes</span>
|
||||
</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
|
||||
value={noteDraft}
|
||||
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) => {
|
||||
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()}
|
||||
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">
|
||||
<input
|
||||
value={tagInput}
|
||||
@@ -2219,6 +2411,10 @@ function App() {
|
||||
`${entry.term}\n${entry.definitions
|
||||
.map((definition) => `${definition.sourceName}: ${definition.text}`)
|
||||
.join('\n\n')}`,
|
||||
{
|
||||
title: entry.term,
|
||||
sourceKind: 'resource',
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -2257,7 +2453,12 @@ function App() {
|
||||
<article
|
||||
key={entry.id}
|
||||
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>
|
||||
<h3>{entry.title || entry.reference}</h3>
|
||||
@@ -2461,7 +2662,12 @@ function App() {
|
||||
<p
|
||||
key={note.id}
|
||||
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>
|
||||
<span>{note.text}</span>
|
||||
@@ -2478,7 +2684,12 @@ function App() {
|
||||
<p
|
||||
key={note.id}
|
||||
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>
|
||||
<span>{note.text}</span>
|
||||
@@ -2698,7 +2909,12 @@ function App() {
|
||||
draggable
|
||||
onMouseEnter={() => setHoveredId(verse.id)}
|
||||
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={() => {
|
||||
setHoveredId(null)
|
||||
selectVerse(verse)
|
||||
@@ -2735,7 +2951,7 @@ function App() {
|
||||
aria-label="Show previous chapters"
|
||||
onClick={() => setChapterTabOffset((current) => current - Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
||||
>
|
||||
‹
|
||||
<
|
||||
</button>
|
||||
{visibleChapterTabs.items.map((chapter) => (
|
||||
<button
|
||||
@@ -2754,7 +2970,7 @@ function App() {
|
||||
aria-label="Show next chapters"
|
||||
onClick={() => setChapterTabOffset((current) => current + Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
||||
>
|
||||
›
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2866,7 +3082,12 @@ function App() {
|
||||
tabIndex={0}
|
||||
onClick={() => recordFootnote(note.reference, note.text)}
|
||||
draggable
|
||||
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
||||
onDragStart={(event) =>
|
||||
startTextDrag(event, `${note.reference} ${note.text}`, {
|
||||
title: note.reference,
|
||||
sourceKind: 'resource',
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -2898,7 +3119,7 @@ function App() {
|
||||
aria-label="Show previous books"
|
||||
onClick={() => setBookTabOffset((current) => current - Math.max(1, bookTabCapacity.visibleCount - 2))}
|
||||
>
|
||||
‹
|
||||
<
|
||||
</button>
|
||||
{visibleBookTabs.items.map((book) => (
|
||||
<button
|
||||
@@ -2918,7 +3139,7 @@ function App() {
|
||||
aria-label="Show next books"
|
||||
onClick={() => setBookTabOffset((current) => current + Math.max(1, bookTabCapacity.visibleCount - 2))}
|
||||
>
|
||||
›
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -126,7 +126,7 @@ function renderMarkdown(exportData: SessionExport) {
|
||||
'## Notes',
|
||||
'',
|
||||
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._',
|
||||
'',
|
||||
'## Timeline',
|
||||
@@ -154,7 +154,7 @@ function renderHtml(exportData: SessionExport) {
|
||||
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
|
||||
</dl>
|
||||
<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>
|
||||
${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>
|
||||
@@ -163,7 +163,7 @@ function renderHtml(exportData: SessionExport) {
|
||||
|
||||
function renderCsv(exportData: SessionExport) {
|
||||
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.session.title,
|
||||
note.id,
|
||||
@@ -177,12 +177,73 @@ function renderCsv(exportData: SessionExport) {
|
||||
note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '',
|
||||
note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '',
|
||||
note.markers?.join(' ') ?? '',
|
||||
String(note.blocks?.length ?? 0),
|
||||
note.blocks?.length ? JSON.stringify(note.blocks) : '',
|
||||
note.body,
|
||||
]),
|
||||
]
|
||||
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.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) {
|
||||
const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook'
|
||||
const reference = note.anchor?.reference ?? note.reference
|
||||
@@ -196,7 +257,7 @@ function markerSummary(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) {
|
||||
|
||||
@@ -42,6 +42,28 @@ export type SessionNote = {
|
||||
translationId?: string
|
||||
anchor?: VerseAnchor
|
||||
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 = {
|
||||
@@ -257,6 +279,7 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
|
||||
translationId: note.translationId ?? session.baseTranslation,
|
||||
anchor: note.anchor,
|
||||
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,
|
||||
updatedAt: now,
|
||||
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 {
|
||||
if (!sessions.length) return ''
|
||||
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
|
||||
|
||||
Reference in New Issue
Block a user