From 73314c494e12fd01748aa80f40d6561745b0d61a Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Jul 2026 19:37:34 -0500 Subject: [PATCH] Complete phase 10 notebook clipping workflow --- CHANGELOG.md | 1 + ROADMAP.md | 3 +- docs/product-tracks.md | 4 +- docs/project-state-2026-07-15.md | 11 ++ package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.css | 131 ++++++++++++++++ src/App.tsx | 251 +++++++++++++++++++++++++++++-- src/sessionPortability.ts | 71 ++++++++- src/workspace.ts | 43 ++++++ 13 files changed, 498 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 712480c..0c5c071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 439bf26..a0d3d0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/product-tracks.md b/docs/product-tracks.md index c7ec82f..2d4a537 100644 --- a/docs/product-tracks.md +++ b/docs/product-tracks.md @@ -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. diff --git a/docs/project-state-2026-07-15.md b/docs/project-state-2026-07-15.md index 63fd19d..7030203 100644 --- a/docs/project-state-2026-07-15.md +++ b/docs/project-state-2026-07-15.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 00bc349..3b49865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3b84067..63558c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b845dfc..0c3f8ad 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.36" +version = "0.1.37" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 884a22b..3bdec84 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.36" +version = "0.1.37" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2b3ef0..1d82868 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.css b/src/App.css index b48642a..800dc66 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index c7ef68b..0ed4364 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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['kind'] + dataUrl?: string + mimeType?: string +} type WindowedItems = { 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) { + 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, 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((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, value: string, payload?: Partial) { 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): ClipPayload { + const raw = event.dataTransfer.getData('application/x-libre-study-clip') + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial + 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() {

startTextDrag(event, `${note.reference} ${note.text}`)} + onDragStart={(event) => + startTextDrag(event, `${note.reference} ${note.text}`, { + title: note.reference, + sourceKind: 'resource', + }) + } > {note.reference} {note.text} @@ -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 (

@@ -2101,16 +2231,78 @@ function App() { {activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes +