fix(web): banner highlight-note dialog prefilled; explicit note boundary; pencil on every note row

- BUG: startBannerEdit extracted the note by splitting row.rich — the
  RENDERED HTML — on \n\n, which the concatenated markup never
  contains, so highlight-note dialogs always opened empty. Both row
  kinds carry the raw note on bannerSources; use that lookup.
- Expanded body now splits at the highlight→note boundary: the
  selection paragraph (left border in the row's highlight color), a
  divider + pencil NOTE label, then the note markdown — instead of the
  two texts flowing together.
- The note-indicator pencil (corner badge) now shows on ALL note rows,
  not just highlights with attached notes.

Verified in headless Chromium: badge on note and highlight rows;
expanded highlight shows the bordered selection, NOTE label, and note
markdown; the highlight edit dialog prefills with the note text.
This commit is contained in:
John O'Keefe committed 2026-10-08 09:11:47 -04:00
1 parent e7ccd7b222
commit 44c0e5640f
4 files changed
+49 -22

No files matched your search

+27 -7
View File
@@ -61,11 +61,11 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
<div class="space-y-2"> <div class="space-y-2">
<template x-for="row in bannerRows" :key="row.key"> <template x-for="row in bannerRows" :key="row.key">
<div class="card p-0 overflow-hidden relative"> <div class="card p-0 overflow-hidden relative">
<!-- Note-attached indicator: quiet stroke glyph in the <!-- Note indicator: a quiet stroke glyph in the card's
card's upper-right corner (never left of text) — upper-right corner (never left of text) — every
highlights with an attached note only. --> note row, and highlights with an attached note. -->
<svg <svg
x-show="row.kind === 'highlight' && row.hasNote" x-show="row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)"
class="absolute top-2 right-2 pointer-events-none reader-icon" class="absolute top-2 right-2 pointer-events-none reader-icon"
style="color: var(--text-secondary);" style="color: var(--text-secondary);"
width="13" height="13" viewBox="0 0 20 20" aria-hidden="true" width="13" height="13" viewBox="0 0 20 20" aria-hidden="true"
@@ -73,7 +73,7 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
<button <button
type="button" type="button"
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover" class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
:class="row.kind === 'highlight' && row.hasNote ? 'pr-8' : ''" :class="(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''"
@click="row.expanded = !row.expanded" @click="row.expanded = !row.expanded"
> >
<span <span
@@ -115,8 +115,28 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
>✕ Delete</button> >✕ Delete</button>
</div> </div>
</div> </div>
<!-- Expanded rich body (markdown pipeline) --> <!-- Expanded rich body (markdown pipeline). Highlight
<div x-show="row.expanded" class="note-rich px-4 pb-4" x-html="row.rich"></div> rows split at the highlight→note boundary: the
selection (bordered in the row color), then a
labeled divider, then the note markdown. -->
<div x-show="row.expanded" class="px-4 pb-4">
<p
x-show="row.kind === 'highlight'"
class="text-sm"
:style="row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'"
x-text="row.richSelection"
></p>
<template x-if="row.kind === 'highlight' && row.hasNote">
<div
class="flex items-center gap-1 pt-2 pb-1 mt-2 border-t"
style="border-color: var(--border); color: var(--text-secondary);"
>
<svg class="reader-icon" width="12" height="12" viewBox="0 0 20 20" aria-hidden="true"><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
<span class="text-xs font-medium uppercase tracking-wide">Note</span>
</div>
</template>
<div class="note-rich text-sm" x-html="row.richNote"></div>
</div>
</div> </div>
</template> </template>
</div> </div>
+1 -1
View File
@@ -50,7 +50,7 @@ func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 relative\"><!-- Note-attached indicator: quiet stroke glyph in the\n\t\t\t\t\t\t card's upper-right corner (never left of text) —\n\t\t\t\t\t\t highlights with an attached note only. --><svg x-show=\"row.kind === 'highlight' && row.hasNote\" class=\"absolute top-2 right-2 pointer-events-none reader-icon\" style=\"color: var(--text-secondary);\" width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg> <button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" :class=\"row.kind === 'highlight' && row.hasNote ? 'pr-8' : ''\" @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 · edit (notes + attached highlight notes) · delete\n\t\t\t\t\t\t (location notes) — objective 8a --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a x-show=\"row.loc\" :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 class=\"flex items-center gap-1\"><button type=\"button\" @click=\"startBannerEdit(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" x-text=\"row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'\"></button> <button type=\"button\" @click=\"row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--status-danger);\">✕ Delete</button></div></div><!-- Expanded rich body (markdown pipeline) --><div x-show=\"row.expanded\" class=\"note-rich px-4 pb-4\" x-html=\"row.rich\"></div></div></template></div></div></div><!-- Note edit dialog (objective 8a): raw markdown source; for\n\t highlight rows the dialog edits the note ATTACHED to the\n\t highlight (never the highlight itself) --><div id=\"banner-note-dialog\" x-cloak x-show=\"bannerEditOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-md w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\" x-text=\"bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'\"></h4><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary);\">Markdown supported</p><textarea x-model=\"bannerEditText\" rows=\"6\" class=\"input w-full text-sm\"></textarea><div class=\"flex gap-2 mt-4\"><button type=\"button\" @click=\"saveBannerNote()\" :disabled=\"!String(bannerEditText || '').trim()\" class=\"flex-1 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\">Save</button> <button type=\"button\" @click=\"bannerEditOpen = false\" class=\"flex-1 py-2 text-sm border rounded hover:bg-surface-hover\" style=\"border-color: var(--border); color: var(--text-secondary);\">Cancel</button></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><divLine truncated 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 relative\"><!-- Note indicator: a quiet stroke glyph in the card's\n\t\t\t\t\t\t upper-right corner (never left of text) — every\n\t\t\t\t\t\t note row, and highlights with an attached note. --><svg x-show=\"row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)\" class=\"absolute top-2 right-2 pointer-events-none reader-icon\" style=\"color: var(--text-secondary);\" width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg> <button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" :class=\"(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''\" @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 · edit (notes + attached highlight notes) · delete\n\t\t\t\t\t\t (location notes) — objective 8a --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a x-show=\"row.loc\" :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 class=\"flex items-center gap-1\"><button type=\"button\" @click=\"startBannerEdit(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" x-text=\"row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'\"></button> <button type=\"button\" @click=\"row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--status-danger);\">✕ Delete</button></div></div><!-- Expanded rich body (markdown pipeline). Highlight\n\t\t\t\t\t\t rows split at the highlight→note boundary: the\n\t\t\t\t\t\t selection (bordered in the row color), then a\n\t\t\t\t\t\t labeled divider, then the note markdown. --><div x-show=\"row.expanded\" class=\"px-4 pb-4\"><p x-show=\"row.kind === 'highlight'\" class=\"text-sm\" :style=\"row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'\" x-text=\"row.richSelection\"></p><template x-if=\"row.kind === 'highlight' && row.hasNote\"><div class=\"flex items-center gap-1 pt-2 pb-1 mt-2 border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\"><svg class=\"reader-icon\" width=\"12\" height=\"12\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg> <span class=\"text-xs font-medium uppercase tracking-wide\">Note</span></div></template><div class=\"note-rich text-sm\" x-html=\"row.richNote\"></div></div></div></template></div></div></div><!-- Note edit dialog (objective 8a): raw markdown source; for\n\t highlight rows the dialog edits the note ATTACHED to the\n\t highlight (never the highlight itself) --><div id=\"banner-note-dialog\" x-cloak x-show=\"bannerEditOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-md w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\" x-text=\"bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'\"></h4><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary);\">MarkdowLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+14 -8
View File
@@ -19,8 +19,11 @@ interface AnnotationRow {
label: string; label: string;
/** compact preview line: stripped markdown (note) or capped selection */ /** compact preview line: stripped markdown (note) or capped selection */
preview: string; preview: string;
/** rich body for the expanded view (pre-sanitized) */ /** expanded view, split so the highlight→note boundary is explicit:
rich: string; * the escaped selection text, then the note's markdown-rendered HTML
* (empty when the row has no note body). */
richSelection: string;
richNote: string;
/** the ?loc= handoff value for the jump */ /** the ?loc= handoff value for the jump */
loc: string; loc: string;
/** row accent color (swatch) */ /** row accent color (swatch) */
@@ -96,7 +99,8 @@ function buildAnnotationRows(
kind: "note", kind: "note",
label, label,
preview: stripNote(content) || "(empty note)", preview: stripNote(content) || "(empty note)",
rich: renderNoteHtml(content), richSelection: "",
richNote: renderNoteHtml(content),
loc, loc,
color: "var(--accent)", color: "var(--accent)",
hasNote: true, hasNote: true,
@@ -135,17 +139,19 @@ function buildAnnotationRows(
} }
const capped = selection.length > 80 ? selection.slice(0, 80) + "…" : selection; const capped = selection.length > 80 ? selection.slice(0, 80) + "…" : selection;
// Expanded body: the full highlight text followed by its rich note. // Expanded body, split at the highlight→note boundary: the escaped
const rich = note // selection, then the note's rendered markdown under a labeled divider
? `<p style="color: var(--text-secondary); margin: 0 0 .5rem;">${escapeHtmlForRich(selection)}</p>` + renderNoteHtml(note) // (rendered by the banner template).
: `<p style="color: var(--text-secondary);">${escapeHtmlForRich(selection) || "…"}</p>`; const richSelection = escapeHtmlForRich(selection);
const richNote = note ? renderNoteHtml(note) : "";
const row: AnnotationRow = { const row: AnnotationRow = {
key: `hl-${h.id}`, key: `hl-${h.id}`,
kind: "highlight", kind: "highlight",
label, label,
preview: capped || "(empty highlight)", preview: capped || "(empty highlight)",
rich, richSelection,
richNote,
loc, loc,
color, color,
hasNote: !!note, hasNote: !!note,
+7 -6
View File
@@ -225,13 +225,14 @@ Alpine.data("bookDetail", () => {
}, },
// ----- note edit/delete at the banner (objective 8a) ----- // ----- note edit/delete at the banner (objective 8a) -----
startBannerEdit(row: AnnotationRow): void { startBannerEdit(row: AnnotationRow): void {
// Highlight rows edit the note ATTACHED to the highlight (never the // Both kinds carry the raw note body on their source: highlights
// highlight itself): rich = "<selection head>\n\n<note source>". // hold the ATTACHED note (never the highlight itself), location
// notes hold their content. The old code parsed row.rich (rendered
// HTML — no \n\n separators in it) and came up empty every time.
this.bannerEditRow = row; this.bannerEditRow = row;
this.bannerEditText = this.bannerEditText = String(
row.kind === "highlight" this.bannerSources.find(s => s.row.key === row.key)?.source ?? "",
? String((row.rich.split("\n\n").slice(1).join("\n\n") || "")).trim() );
: String(this.bannerSources.find(s => s.row.key === row.key)?.source ?? "");
this.bannerEditOpen = true; this.bannerEditOpen = true;
}, },
async saveBannerNote() { async saveBannerNote() {