Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b83db8fce | ||
|
|
4b5e193d9f |
+9
-1
@@ -15,13 +15,21 @@
|
|||||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"dompurify": "^3.4.16",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"htmx.org": "^2.0.8",
|
"htmx.org": "^2.0.8",
|
||||||
"lunr": "^2.3.9"
|
"lunr": "^2.3.9",
|
||||||
|
"markdown-it": "^15.0.2",
|
||||||
|
"markdown-it-footnote": "^4.0.0",
|
||||||
|
"turndown": "^7.2.4",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "bookhoard/internal/handlers"
|
||||||
|
|
||||||
|
// AnnotationsBanner is the book-detail slide-out listing every note and
|
||||||
|
// highlight on the book (objective 7). Rows show a friendly human location
|
||||||
|
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
|
||||||
|
// (markdown pipeline identical to the reader's), and offer a per-row jump
|
||||||
|
// that opens the reader at the annotation's location via ?loc=. Web-only
|
||||||
|
// export lives at the top: single .md file, or one file per item zipped.
|
||||||
|
// Formatting is IDENTICAL in both modes:
|
||||||
|
// location note: "# <friendly label>" header + note source as body
|
||||||
|
// highlight: "# <highlight text>" header + note source as body
|
||||||
|
// (no note → header only)
|
||||||
|
templ AnnotationsBanner(book handlers.MediaDetail) {
|
||||||
|
<div
|
||||||
|
id="annotations-banner"
|
||||||
|
x-cloak
|
||||||
|
x-show="annotationsBannerOpen"
|
||||||
|
x-transition:enter="transition-transform duration-200 ease-out"
|
||||||
|
x-transition:enter-start="translate-x-full"
|
||||||
|
x-transition:enter-end="translate-x-0"
|
||||||
|
x-transition:leave="transition-transform duration-150 ease-in"
|
||||||
|
x-transition:leave-start="translate-x-0"
|
||||||
|
x-transition:leave-end="translate-x-full"
|
||||||
|
class="fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto"
|
||||||
|
style="background: var(--bg-primary); border-left: 1px solid var(--border);"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Annotations"
|
||||||
|
>
|
||||||
|
<div class="sticky top-0 px-4 py-3 flex items-center justify-between" style="background: var(--bg-primary); border-bottom: 1px solid var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary);">Notes & Highlights</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="hideAnnotationsBanner()"
|
||||||
|
class="p-2 rounded-lg hover:bg-surface-hover"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
aria-label="Close annotations"
|
||||||
|
>
|
||||||
|
@Icon("close", "w-5 h-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="export-annotations-btn"
|
||||||
|
:disabled="annotationsBannerLoading || bannerRows.length === 0"
|
||||||
|
@click="showExportDialog()"
|
||||||
|
class="w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
Export all notes and highlights
|
||||||
|
</button>
|
||||||
|
<p x-show="annotationsBannerLoading" class="text-sm py-4" style="color: var(--text-secondary);">Loading…</p>
|
||||||
|
<template x-if="!annotationsBannerLoading && bannerRows.length === 0">
|
||||||
|
<p class="text-sm py-4" style="color: var(--text-secondary);">
|
||||||
|
No notes or highlights on this book yet.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<!-- Notes first, then highlights — stable ledger order -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="row in bannerRows" :key="row.key">
|
||||||
|
<div class="card p-0 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
|
||||||
|
@click="row.expanded = !row.expanded"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 self-stretch rounded-full shrink-0"
|
||||||
|
:style="{ backgroundColor: row.color }"
|
||||||
|
></span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
class="block text-sm"
|
||||||
|
x-text="row.preview"
|
||||||
|
></span>
|
||||||
|
<span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<!-- Jump opens the reader at the annotation's location -->
|
||||||
|
<div class="flex items-center justify-between px-4 pb-3 pt-1">
|
||||||
|
<a
|
||||||
|
:href="'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)"
|
||||||
|
class="text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors"
|
||||||
|
style="border-color: var(--border); color: var(--accent);"
|
||||||
|
>
|
||||||
|
Open reader here ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded rich body (markdown pipeline) -->
|
||||||
|
<div x-show="row.expanded" class="px-4 pb-4" x-html="row.rich"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export format dialog -->
|
||||||
|
<div
|
||||||
|
id="annotations-export-dialog"
|
||||||
|
x-cloak
|
||||||
|
x-show="annotationsExportOpen"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style="background: rgba(0,0,0,.6);"
|
||||||
|
>
|
||||||
|
<div class="max-w-sm w-full card p-6">
|
||||||
|
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);">Export annotations</h4>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary);">Notes and highlights, as markdown files.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="exportAnnotations('single')"
|
||||||
|
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
Single file — everything in one .md
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="exportAnnotations('multi')"
|
||||||
|
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
One file per item — zipped
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="hideExportDialog()"
|
||||||
|
class="w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "bookhoard/internal/handlers"
|
||||||
|
|
||||||
|
// AnnotationsBanner is the book-detail slide-out listing every note and
|
||||||
|
// highlight on the book (objective 7). Rows show a friendly human location
|
||||||
|
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
|
||||||
|
// (markdown pipeline identical to the reader's), and offer a per-row jump
|
||||||
|
// that opens the reader at the annotation's location via ?loc=. Web-only
|
||||||
|
// export lives at the top: single .md file, or one file per item zipped.
|
||||||
|
// Formatting is IDENTICAL in both modes:
|
||||||
|
//
|
||||||
|
// location note: "# <friendly label>" header + note source as body
|
||||||
|
// highlight: "# <highlight text>" header + note source as body
|
||||||
|
// (no note → header only)
|
||||||
|
func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"annotations-banner\" x-cloak x-show=\"annotationsBannerOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto\" style=\"background: var(--bg-primary); border-left: 1px solid var(--border);\" role=\"dialog\" aria-label=\"Annotations\"><div class=\"sticky top-0 px-4 py-3 flex items-center justify-between\" style=\"background: var(--bg-primary); border-bottom: 1px solid var(--border);\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary);\">Notes & Highlights</h3><button type=\"button\" @click=\"hideAnnotationsBanner()\" class=\"p-2 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close annotations\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("close", "w-5 h-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</button></div><div class=\"px-4 py-3\"><button type=\"button\" id=\"export-annotations-btn\" :disabled=\"annotationsBannerLoading || bannerRows.length === 0\" @click=\"showExportDialog()\" class=\"w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed\" style=\"border-color: var(--border); color: var(--text-primary);\">Export all notes and highlights</button><p x-show=\"annotationsBannerLoading\" class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">Loading…</p><template x-if=\"!annotationsBannerLoading && bannerRows.length === 0\"><p class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">No notes or highlights on this book yet.</p></template><!-- Notes first, then highlights — stable ledger order --><div class=\"space-y-2\"><template x-for=\"row in bannerRows\" :key=\"row.key\"><div class=\"card p-0 overflow-hidden\"><button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" @click=\"row.expanded = !row.expanded\"><span class=\"w-1.5 self-stretch rounded-full shrink-0\" :style=\"{ backgroundColor: row.color }\"></span> <span class=\"flex-1 min-w-0\"><span class=\"block text-sm\" x-text=\"row.preview\"></span> <span class=\"block text-xs mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"row.label\"></span></span></button><!-- Jump opens the reader at the annotation's location --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a :href=\"'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)\" class=\"text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--accent);\">Open reader here ↗</a></div><!-- Expanded rich body (markdown pipeline) --><div x-show=\"row.expanded\" class=\"px-4 pb-4\" x-html=\"row.rich\"></div></div></template></div></div></div><!-- Export format dialog --><div id=\"annotations-export-dialog\" x-cloak x-show=\"annotationsExportOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-sm w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\">Export annotations</h4><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Notes and highlights, as markdown files.</p><div class=\"space-y-2\"><button type=\"button\" @click=\"exportAnnotations('single')\" class=\"w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Single file — everything in one .md</button> <button type=\"button\" @click=\"exportAnnotations('multi')\" class=\"w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">One file per item — zipped</button></div><button type=\"button\" @click=\"hideExportDialog()\" class=\"w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">Cancel</button></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -107,6 +107,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
|
||||||
|
@Icon("bookmark", "h-4 w-4")
|
||||||
|
<span>Annotations</span>
|
||||||
|
if book.NotesCount + book.HighlightsCount > 0 {
|
||||||
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
@@ -434,6 +441,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(user, book)
|
@NotesHighlightsModal(user, book)
|
||||||
|
@AnnotationsBanner(book)
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
}
|
}
|
||||||
|
|||||||
+488
-453
File diff suppressed because it is too large
Load Diff
@@ -178,7 +178,9 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
rows="3"
|
rows="3"
|
||||||
class="reader-note-input"
|
class="reader-note-input"
|
||||||
placeholder="Note…"
|
placeholder="Note…"
|
||||||
|
@paste="richNotePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||||||
<div
|
<div
|
||||||
class="text-xs mt-0.5 text-right"
|
class="text-xs mt-0.5 text-right"
|
||||||
x-show="selectionPopover.note.length > 0"
|
x-show="selectionPopover.note.length > 0"
|
||||||
@@ -634,7 +636,7 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
|
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
|
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
|
||||||
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-text="'📝 ' + hl.note"></span>
|
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-html="'📝 ' + hl.noteHtml"></span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@click="deleteHighlightById(hl.id)"
|
@click="deleteHighlightById(hl.id)"
|
||||||
@@ -657,7 +659,9 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
rows="2"
|
rows="2"
|
||||||
class="reader-note-input"
|
class="reader-note-input"
|
||||||
placeholder="Add a note at the current position…"
|
placeholder="Add a note at the current position…"
|
||||||
|
@paste="richNotePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||||||
<div
|
<div
|
||||||
class="text-xs mt-0.5 text-right"
|
class="text-xs mt-0.5 text-right"
|
||||||
x-show="newNoteText.length > 0"
|
x-show="newNoteText.length > 0"
|
||||||
@@ -675,7 +679,7 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
<template x-for="note in noteItems" :key="note.id">
|
<template x-for="note in noteItems" :key="note.id">
|
||||||
<div class="reader-hl-row group">
|
<div class="reader-hl-row group">
|
||||||
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
||||||
<span class="block text-sm truncate" x-text="note.content"></span>
|
<span class="block text-sm truncate reader-note-rich" x-html="note.contentHtml"></span>
|
||||||
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,197 @@
|
|||||||
|
// Book-detail annotations banner (objective 7): every note and highlight
|
||||||
|
// on the book, friendly location labels (never a raw cfi:epubcfi(…) string
|
||||||
|
// in UI or export headers), per-row reader jumps via ?loc=, and the
|
||||||
|
// WEB-ONLY export (single .md or one-file-per-item zip). Item formatting
|
||||||
|
// is identical in both modes:
|
||||||
|
// location note: "# <friendly label>" header + note source as body
|
||||||
|
// highlight: "# <highlight text>" header + note source as body
|
||||||
|
// (no note → header only)
|
||||||
|
// Rows expand to the rich markup through the same markdown pipeline the
|
||||||
|
// reader uses (renderNoteHtml — sanitized at render time).
|
||||||
|
import { renderNoteHtml, stripNote } from "./markdown";
|
||||||
|
|
||||||
|
interface AnnotationRow {
|
||||||
|
/** stable key for x-for */
|
||||||
|
key: string;
|
||||||
|
/** note | highlight */
|
||||||
|
kind: "note" | "highlight";
|
||||||
|
/** human location label (Section N / Page N / NN% / source words) */
|
||||||
|
label: string;
|
||||||
|
/** compact preview line: stripped markdown (note) or capped selection */
|
||||||
|
preview: string;
|
||||||
|
/** rich body for the expanded view (pre-sanitized) */
|
||||||
|
rich: string;
|
||||||
|
/** the ?loc= handoff value for the jump */
|
||||||
|
loc: string;
|
||||||
|
/** row accent color (swatch) */
|
||||||
|
color: string;
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnotationSource {
|
||||||
|
row: AnnotationRow;
|
||||||
|
/** full markdown body under the export header ("" when none) */
|
||||||
|
source: string;
|
||||||
|
/** highlight selection text (the export header for highlight rows) */
|
||||||
|
highlightText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Friendly human location label; mirrors the app's positionHint(). */
|
||||||
|
function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string {
|
||||||
|
const raw = (position ?? "").trim();
|
||||||
|
if (raw) {
|
||||||
|
// PDF/comic anchors: "page:N"
|
||||||
|
if (raw.startsWith("page:")) {
|
||||||
|
const n = parseInt(raw.slice(5), 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) return `Page ${n}`;
|
||||||
|
}
|
||||||
|
// Reflowable location notes: "cfi:epubcfi(...)"
|
||||||
|
const cfi = raw.startsWith("cfi:") ? raw.slice(4).trim() : raw;
|
||||||
|
const m = /epubcfi\(\s*\/6\/(\d+)/.exec(cfi);
|
||||||
|
if (m) {
|
||||||
|
// The spine step counts 1-based element pairs; /6/N!/ → section N/2.
|
||||||
|
const spine = Math.floor(parseInt(m[1], 10) / 2) - 1;
|
||||||
|
if (spine >= 0) return `Section ${spine + 1}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Spec fallback: the text's first few words (never a raw locator).
|
||||||
|
const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (words) {
|
||||||
|
const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
return "Location";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF highlights store their page inside the epubcfi_start column as a
|
||||||
|
* JSON descriptor {"v":1,"page":N,"rects":[…]} — parse it off. */
|
||||||
|
function pdfEnvelopePage(cfi: string): number | null {
|
||||||
|
if (cfi.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const a = JSON.parse(cfi);
|
||||||
|
if (a && typeof a.page === "number") return a.page;
|
||||||
|
} catch {
|
||||||
|
/* not ours */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnnotationRows(
|
||||||
|
notes: any[],
|
||||||
|
highlights: any[],
|
||||||
|
): { rows: AnnotationRow[]; sources: AnnotationSource[] } {
|
||||||
|
const rows: AnnotationRow[] = [];
|
||||||
|
const sources: AnnotationSource[] = [];
|
||||||
|
|
||||||
|
for (const n of notes) {
|
||||||
|
const content = n.content ?? "";
|
||||||
|
const label = friendlyAnnotationLabel(n.position, content);
|
||||||
|
const loc = n.position ?? "";
|
||||||
|
const row: AnnotationRow = {
|
||||||
|
key: `note-${n.id}`,
|
||||||
|
kind: "note",
|
||||||
|
label,
|
||||||
|
preview: stripNote(content) || "(empty note)",
|
||||||
|
rich: renderNoteHtml(content),
|
||||||
|
loc,
|
||||||
|
color: "var(--accent)",
|
||||||
|
expanded: false,
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
sources.push({
|
||||||
|
row,
|
||||||
|
source: content,
|
||||||
|
highlightText: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const h of highlights) {
|
||||||
|
const selection = (h.selection_text ?? "").trim();
|
||||||
|
const note = (h.note_text ?? "").trim();
|
||||||
|
const color = h.color || "#ffff00";
|
||||||
|
const envelopePage = pdfEnvelopePage(h.epubcfi_start ?? "");
|
||||||
|
const cfi = (h.epubcfi_start ?? "").trim();
|
||||||
|
const pct = typeof h.percentage_start === "number" ? h.percentage_start : null;
|
||||||
|
|
||||||
|
let label = "Location";
|
||||||
|
let loc = "";
|
||||||
|
if (envelopePage != null) {
|
||||||
|
label = `Page ${envelopePage + 1}`;
|
||||||
|
// ?loc= speaks 1-based pages
|
||||||
|
loc = `page:${envelopePage + 1}`;
|
||||||
|
} else if (cfi || pct != null) {
|
||||||
|
label = friendlyAnnotationLabel(cfi || null, selection);
|
||||||
|
}
|
||||||
|
if (envelopePage == null && cfi.startsWith("epubcfi(")) {
|
||||||
|
loc = `cfi:${cfi}`;
|
||||||
|
}
|
||||||
|
if (!loc && pct != null) {
|
||||||
|
label = `${Math.round(pct * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capped = selection.length > 80 ? selection.slice(0, 80) + "…" : selection;
|
||||||
|
// Expanded body: the full highlight text followed by its rich note.
|
||||||
|
const rich = note
|
||||||
|
? `<p style="color: var(--text-secondary); margin: 0 0 .5rem;">${escapeHtmlForRich(selection)}</p>` + renderNoteHtml(note)
|
||||||
|
: `<p style="color: var(--text-secondary);">${escapeHtmlForRich(selection) || "…"}</p>`;
|
||||||
|
|
||||||
|
const row: AnnotationRow = {
|
||||||
|
key: `hl-${h.id}`,
|
||||||
|
kind: "highlight",
|
||||||
|
label,
|
||||||
|
preview: capped || "(empty highlight)",
|
||||||
|
rich,
|
||||||
|
loc,
|
||||||
|
color,
|
||||||
|
expanded: false,
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
sources.push({ row, source: note, highlightText: selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, sources };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlForRich(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The export item text — identical in single and multi modes. */
|
||||||
|
function annotationItemMarkdown(source: AnnotationSource): string {
|
||||||
|
if (source.row.kind === "highlight") {
|
||||||
|
const body = source.source ? "\n\n" + source.source.trim() : "";
|
||||||
|
return `# ${source.highlightText || "(highlight)"}${body}\n`;
|
||||||
|
}
|
||||||
|
const body = source.source.trim();
|
||||||
|
return `# ${source.row.label}\n${body ? "\n" + body : ""}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bannerBookTitle(): string {
|
||||||
|
const h1 = document.querySelector("h1")?.textContent?.trim();
|
||||||
|
const clean = (h1 || "").replace(/[\\/:*?"<>|]/g, "").slice(0, 80);
|
||||||
|
return clean || "book";
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildAnnotationRows,
|
||||||
|
annotationItemMarkdown,
|
||||||
|
triggerDownload,
|
||||||
|
bannerBookTitle,
|
||||||
|
type AnnotationRow,
|
||||||
|
type AnnotationSource,
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||||
|
import { strToU8, zipSync } from "fflate";
|
||||||
|
import {
|
||||||
|
buildAnnotationRows,
|
||||||
|
annotationItemMarkdown,
|
||||||
|
triggerDownload,
|
||||||
|
bannerBookTitle,
|
||||||
|
type AnnotationRow,
|
||||||
|
type AnnotationSource,
|
||||||
|
} from "./annotations-banner";
|
||||||
|
|
||||||
function getMediaId(): string {
|
function getMediaId(): string {
|
||||||
const parts = window.location.pathname.split("/");
|
const parts = window.location.pathname.split("/");
|
||||||
@@ -157,6 +166,17 @@ Alpine.data("bookDetail", () => {
|
|||||||
showTagDropdown: false,
|
showTagDropdown: false,
|
||||||
highlightedTagIndex: -1,
|
highlightedTagIndex: -1,
|
||||||
|
|
||||||
|
// ----- annotations banner (objective 7) -----
|
||||||
|
annotationsBannerOpen: false,
|
||||||
|
annotationsBannerLoading: false,
|
||||||
|
annotationsExportOpen: false,
|
||||||
|
bannerMediaId: getMediaId(),
|
||||||
|
bannerRows: [] as AnnotationRow[],
|
||||||
|
bannerLoaded: false,
|
||||||
|
bannerError: "",
|
||||||
|
// raw source kept for the export builder (not exposed to the template)
|
||||||
|
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
|
||||||
|
|
||||||
showReaderPlaceholder,
|
showReaderPlaceholder,
|
||||||
showMetadataEditor,
|
showMetadataEditor,
|
||||||
hideMetadataEditor,
|
hideMetadataEditor,
|
||||||
@@ -165,6 +185,77 @@ Alpine.data("bookDetail", () => {
|
|||||||
hideProgressSyncModal,
|
hideProgressSyncModal,
|
||||||
hideNotesModal,
|
hideNotesModal,
|
||||||
|
|
||||||
|
async toggleAnnotationsBanner() {
|
||||||
|
this.annotationsBannerOpen = !this.annotationsBannerOpen;
|
||||||
|
if (this.annotationsBannerOpen && !this.bannerLoaded) {
|
||||||
|
await this.fetchBannerAnnotations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hideAnnotationsBanner(): void {
|
||||||
|
this.annotationsBannerOpen = false;
|
||||||
|
this.hideExportDialog();
|
||||||
|
},
|
||||||
|
async fetchBannerAnnotations() {
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
this.annotationsBannerLoading = true;
|
||||||
|
try {
|
||||||
|
const [noteResp, hlResp] = await Promise.all([
|
||||||
|
fetch(`/api/media-items/${mediaId}/notes`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
}),
|
||||||
|
fetch(`/api/media-items/${mediaId}/highlights`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const notes = noteResp.ok ? await noteResp.json() : [];
|
||||||
|
const highlights = hlResp.ok ? await hlResp.json() : [];
|
||||||
|
const built = buildAnnotationRows(notes, highlights);
|
||||||
|
this.bannerRows = built.rows;
|
||||||
|
this.bannerSources = built.sources;
|
||||||
|
this.bannerLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.bannerError = String(e);
|
||||||
|
showToast("Could not load annotations", "error");
|
||||||
|
} finally {
|
||||||
|
this.annotationsBannerLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showExportDialog(): void {
|
||||||
|
if (this.bannerRows.length === 0) return;
|
||||||
|
this.annotationsExportOpen = true;
|
||||||
|
},
|
||||||
|
hideExportDialog(): void {
|
||||||
|
this.annotationsExportOpen = false;
|
||||||
|
},
|
||||||
|
async exportAnnotations(mode: "single" | "multi") {
|
||||||
|
this.hideExportDialog();
|
||||||
|
if (this.bannerRows.length === 0) return;
|
||||||
|
const items = this.bannerSources.map(annotationItemMarkdown);
|
||||||
|
const title = bannerBookTitle();
|
||||||
|
try {
|
||||||
|
if (mode === "single") {
|
||||||
|
const blob = new Blob([items.join("\n")], {
|
||||||
|
type: "text/markdown",
|
||||||
|
});
|
||||||
|
triggerDownload(blob, `${title}-annotations.md`);
|
||||||
|
} else {
|
||||||
|
// One file per item: 1.md … N.md, zipped client-side.
|
||||||
|
const files: Record<string, Uint8Array> = {};
|
||||||
|
items.forEach((text, i) => {
|
||||||
|
files[`${i + 1}.md`] = strToU8(text);
|
||||||
|
});
|
||||||
|
const zipped = zipSync(files);
|
||||||
|
triggerDownload(
|
||||||
|
new Blob([zipped], { type: "application/zip" }),
|
||||||
|
`${title}-annotations.zip`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showToast("Annotations exported", "success");
|
||||||
|
} catch (_e) {
|
||||||
|
showToast("Export failed", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async resolveConflict(conflictId: string, winner: string) {
|
async resolveConflict(conflictId: string, winner: string) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Notes render as markdown: storage and every sync payload carry the raw
|
||||||
|
// source string (KOReader devices show it literally — accepted), and every
|
||||||
|
// DISPLAY site renders it through this pipeline. Raw HTML is disabled at the
|
||||||
|
// parser level (markdown-it's default) and DOMPurify strips whatever the
|
||||||
|
// enabled syntax still produced (link hrefs are scheme-whitelisted below),
|
||||||
|
// so a malicious note can render nothing more than styled text.
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import footnote from "markdown-it-footnote";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import TurndownService from "turndown";
|
||||||
|
import { gfm, tables } from "turndown-plugin-gfm";
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: false, linkify: false });
|
||||||
|
md.use(footnote);
|
||||||
|
|
||||||
|
export function renderNoteHtml(source: string): string {
|
||||||
|
if (!source) return "";
|
||||||
|
const raw = md.render(source);
|
||||||
|
// Disallowing img/input/style/form by tag and keeping DOMPurify's default
|
||||||
|
// safe-URI scheme check (http/https/mailto + same-page anchors only)
|
||||||
|
// closes the link-injection hole, which is the one markdown pivot into
|
||||||
|
// script execution.
|
||||||
|
const clean = DOMPurify.sanitize(raw, {
|
||||||
|
ALLOWED_ATTR: ["href", "class", "id", "role"],
|
||||||
|
FORBID_TAGS: ["img", "input", "style", "form"],
|
||||||
|
});
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact rows may show a plain line instead of rich markup.
|
||||||
|
export function stripNote(source: string): string {
|
||||||
|
if (!source) return "";
|
||||||
|
const holder = document.createElement("div");
|
||||||
|
holder.innerHTML = renderNoteHtml(source);
|
||||||
|
return (holder.textContent || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- rich paste: HTML flavor -> markdown source -----
|
||||||
|
const turndown = new TurndownService({
|
||||||
|
// Keep the converted output inside our supported syntax: headings,
|
||||||
|
// emphasis, lists, code, quotes, links, tables. Everything else degrades.
|
||||||
|
headingStyle: "atx",
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
bulletListMarker: "-",
|
||||||
|
hr: "---",
|
||||||
|
});
|
||||||
|
turndown.use(gfm);
|
||||||
|
turndown.use(tables);
|
||||||
|
// Images are explicitly out of scope for notes: blank them at convert time
|
||||||
|
// (remove() alone can leave the node's alt-text shape behind).
|
||||||
|
turndown.addRule("noImages", { filter: "img", replacement: () => "" });
|
||||||
|
// The gfm tables service only converts tables with a heading row and keeps
|
||||||
|
// headingless ones as raw HTML — which our no-raw-HTML parser would then
|
||||||
|
// show as literal tags. Convert those to pipe syntax with a blank header.
|
||||||
|
turndown.addRule("tablesNoHeading", {
|
||||||
|
filter: (node: Node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.nodeName !== "TABLE" || !el.querySelector) return false;
|
||||||
|
return !el.querySelector("th") && el.querySelectorAll("tr").length > 0;
|
||||||
|
},
|
||||||
|
replacement: (_content: string, node: Node) => {
|
||||||
|
const table = node as HTMLTableElement;
|
||||||
|
const rows = Array.from(table.querySelectorAll("tr")).map((tr) =>
|
||||||
|
Array.from(tr.querySelectorAll("td, th")).map((cell) =>
|
||||||
|
(cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cols = Math.max(1, ...rows.map((r) => r.length));
|
||||||
|
const pad = (r: string[]) => {
|
||||||
|
const copy = [...r];
|
||||||
|
while (copy.length < cols) copy.push("");
|
||||||
|
return copy.map((c) => c || " ");
|
||||||
|
};
|
||||||
|
const head = pad(rows[0] ?? []);
|
||||||
|
const lines = [
|
||||||
|
"| " + head.join(" | ") + " |",
|
||||||
|
"|" + Array.from({ length: cols }, () => " --- ").join("|") + "|",
|
||||||
|
...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"),
|
||||||
|
];
|
||||||
|
return "\n\n" + lines.join("\n") + "\n\n";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function htmlToMarkdown(html: string): string {
|
||||||
|
return turndown.turndown(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the paste event's clipboard and returns markdown converted from the
|
||||||
|
* rich (text/html) flavor, or null when the clipboard holds no usable rich
|
||||||
|
* content (plain pastes must fall through to the browser default). */
|
||||||
|
export function richPasteToMarkdown(event: ClipboardEvent): string | null {
|
||||||
|
const data = event.clipboardData;
|
||||||
|
if (!data) return null;
|
||||||
|
const html = data.getData("text/html");
|
||||||
|
if (!html || !html.trim()) return null;
|
||||||
|
// A rich flavor that is just an HTML wrapper of plain text (some apps
|
||||||
|
// emit <p>...</p> wrappers for everything Chrome copies) still converts
|
||||||
|
// harmlessly to the same text, so no content sniffing is needed here.
|
||||||
|
const markdown = htmlToMarkdown(html);
|
||||||
|
if (!markdown || !markdown.trim()) return null;
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Alpine } from "../alpine";
|
|||||||
import { loadSettings, saveSettings } from "./settings-manager";
|
import { loadSettings, saveSettings } from "./settings-manager";
|
||||||
import { getToken } from "../storage";
|
import { getToken } from "../storage";
|
||||||
import { showToast } from "../toast";
|
import { showToast } from "../toast";
|
||||||
|
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
|
||||||
import {
|
import {
|
||||||
extractPdfPages,
|
extractPdfPages,
|
||||||
searchPdfPages,
|
searchPdfPages,
|
||||||
@@ -435,6 +436,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
noteHtml: string;
|
||||||
color: string;
|
color: string;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
cfiEnd: string;
|
cfiEnd: string;
|
||||||
@@ -443,7 +445,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
pdfPage: number;
|
pdfPage: number;
|
||||||
pdfRects: number[][];
|
pdfRects: number[][];
|
||||||
}[],
|
}[],
|
||||||
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
noteItems: [] as { id: string; content: string; contentHtml: string; positionLabel: string }[],
|
||||||
annotationsTab: "highlights" as string,
|
annotationsTab: "highlights" as string,
|
||||||
newNoteText: "",
|
newNoteText: "",
|
||||||
// Mirrors the server-side validator cap on note content
|
// Mirrors the server-side validator cap on note content
|
||||||
@@ -932,12 +934,20 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.handleKeydown(ev),
|
this.handleKeydown(ev),
|
||||||
);
|
);
|
||||||
// Reading position comes from the database, fetched fresh at open
|
// Reading position comes from the database, fetched fresh at open
|
||||||
// (the rendered page carries no snapshot of it).
|
// (the rendered page carries no snapshot of it) — UNLESS an
|
||||||
const saved = await this.fetchSavedLocation();
|
// annotations-banner jump handed one in via ?loc= (objective 7:
|
||||||
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
|
// the jump outranks the stored progress for this open only).
|
||||||
|
const savedLoc = await this.fetchSavedLocation();
|
||||||
|
const jump = this.consumeLocJump();
|
||||||
|
const saved = jump ?? savedLoc;
|
||||||
|
if (this.isFixedLayout && jump?.page != null) {
|
||||||
|
await this.view.init({ lastLocation: jump.page - 1 });
|
||||||
|
} else if (this.isFixedLayout && saved.page != null && saved.page > 0) {
|
||||||
// Fixed-layout & comics: a page index is the exact, universal locator.
|
// Fixed-layout & comics: a page index is the exact, universal locator.
|
||||||
// A bare number navigates directly to the section index in foliate.
|
// A bare number navigates directly to the section index in foliate.
|
||||||
await this.view.init({ lastLocation: saved.page - 1 })
|
await this.view.init({ lastLocation: saved.page - 1 })
|
||||||
|
} else if (jump?.cfi) {
|
||||||
|
await this.view.init({ lastLocation: jump.cfi })
|
||||||
} else if (saved.cfi) {
|
} else if (saved.cfi) {
|
||||||
await this.view.init({ lastLocation: saved.cfi })
|
await this.view.init({ lastLocation: saved.cfi })
|
||||||
} else if (saved.percentage != null && saved.percentage > 0) {
|
} else if (saved.percentage != null && saved.percentage > 0) {
|
||||||
@@ -1556,6 +1566,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
text: r.selection_text ?? "",
|
text: r.selection_text ?? "",
|
||||||
note: r.note_text ?? "",
|
note: r.note_text ?? "",
|
||||||
|
noteHtml: renderNoteHtml(r.note_text ?? ""),
|
||||||
color: r.color ?? "#ffff00",
|
color: r.color ?? "#ffff00",
|
||||||
cfi,
|
cfi,
|
||||||
cfiEnd,
|
cfiEnd,
|
||||||
@@ -1642,6 +1653,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.noteItems = (rows as any[]).map((r) => ({
|
this.noteItems = (rows as any[]).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
content: r.content ?? "",
|
content: r.content ?? "",
|
||||||
|
contentHtml: renderNoteHtml(r.content ?? ""),
|
||||||
positionLabel: r.position ?? "",
|
positionLabel: r.position ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -2021,6 +2033,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
noteCountLabel(text: string): string {
|
noteCountLabel(text: string): string {
|
||||||
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
||||||
},
|
},
|
||||||
|
// Rich clipboard (text/html) converts to markdown source at the caret;
|
||||||
|
// plain-only clipboards fall through to the browser default paste.
|
||||||
|
richNotePaste(event: ClipboardEvent) {
|
||||||
|
const markdown = richPasteToMarkdown(event);
|
||||||
|
if (markdown === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const el = event.target as HTMLTextAreaElement | null;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart ?? el.value.length;
|
||||||
|
const end = el.selectionEnd ?? start;
|
||||||
|
el.value = el.value.slice(0, start) + markdown + el.value.slice(end);
|
||||||
|
const caret = start + markdown.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
},
|
||||||
async addNote(content: string) {
|
async addNote(content: string) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || !content.trim()) return;
|
if (!token || !this.mediaItemId || !content.trim()) return;
|
||||||
@@ -2103,6 +2130,28 @@ document.addEventListener("alpine:init", () => {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** ?loc= handoff from the book-detail annotations banner (objective 7):
|
||||||
|
* `cfi:<epubcfi>` or `page:<1-based>`; outranks the stored progress for
|
||||||
|
* this open only. Parsed once; consumed + stripped from the URL so a
|
||||||
|
* reload (or device sync / back navigation) restores normally. */
|
||||||
|
consumeLocJump(): { cfi?: string; page?: number } | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const raw = params.get("loc");
|
||||||
|
if (!raw) return null;
|
||||||
|
params.delete("loc");
|
||||||
|
const qs = params.toString();
|
||||||
|
const base = window.location.pathname;
|
||||||
|
window.history.replaceState({}, "", base + (qs ? `?${qs}` : ""));
|
||||||
|
const value = decodeURIComponent(raw);
|
||||||
|
if (value.startsWith("cfi:")) {
|
||||||
|
return { cfi: value.slice(4) };
|
||||||
|
}
|
||||||
|
if (value.startsWith("page:")) {
|
||||||
|
const n = parseInt(value.slice(5), 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) return { page: n };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||||
// Only an actual change from the last stored position writes
|
// Only an actual change from the last stored position writes
|
||||||
// progress: displaying a restored position must never overwrite a
|
// progress: displaying a restored position must never overwrite a
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module "markdown-it-footnote";
|
||||||
|
declare module "turndown-plugin-gfm";
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user