fix(reader): make device-synced highlights editable on the web

Device-synced highlights paint with a synthesized range CFI (renderCfi,
built by toRangeCfi from the stored point CFIs and selection text) while
the stored locator stays a point CFI. Three follow-ons from that split:

- show-annotation (click-to-edit) matched the clicked value against the
  stored point cfi only, so clicking a device-created highlight never
  opened the edit popover — it listed in the drawer but was uneditable.
  Match either the stored cfi or the renderCfi the overlay was added by.

- deleteHighlightById removed the overlay with the stored point cfi,
  which never matched the painted value; the highlight box lingered
  until reload. Delete with the value it was added by.

- saveHighlightChanges re-added the overlay without removing the old
  value; an edit that changes the synthesized range (note/text edits
  change the UTF-16 length it derives from) would ghost the old paint
  beside the new one. Remove the previous overlay value first when the
  edit changed it.

Web-created highlights are unaffected: their stored CFI is already a
native range, so renderCfi === cfi for them.
This commit is contained in:
2026-09-09 12:38:24 -04:00
parent e40530824e
commit 2366faccce
+28 -2
View File
@@ -826,7 +826,13 @@ document.addEventListener("alpine:init", () => {
}); });
this.view.addEventListener("show-annotation", (e: any) => { this.view.addEventListener("show-annotation", (e: any) => {
const { value, index, range } = e.detail; const { value, index, range } = e.detail;
const h = this.highlightItems.find((x) => x.cfi === value); // Device-synced highlights are painted with a synthesized range
// CFI (renderCfi) while the stored locator stays a point CFI, so a
// click reports the render value — match either or editing
// device highlights is impossible.
const h = this.highlightItems.find(
(x) => x.cfi === value || x.renderCfi === value,
);
if (!h) return; if (!h) return;
const doc = this.renderer const doc = this.renderer
?.getContents?.() ?.getContents?.()
@@ -1343,7 +1349,23 @@ document.addEventListener("alpine:init", () => {
if (!resp.ok) return; if (!resp.ok) return;
const row = await resp.json(); const row = await resp.json();
const idx = this.highlightItems.findIndex((h) => h.id === p.id); const idx = this.highlightItems.findIndex((h) => h.id === p.id);
// The overlay is keyed by the value it was added with; an edit can
// change it (note/text edits change the synthesized range), so
// remove the old paint before re-adding or it ghosts.
const oldValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row); if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
const newValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
this.view?.deleteAnnotation({ value: oldValue });
}
// Re-add so the overlay redraws with the new color. // Re-add so the overlay redraws with the new color.
if (p.pdfPage >= 0) { if (p.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({ this.renderer?.addRectAnnotation?.({
@@ -1379,7 +1401,11 @@ document.addEventListener("alpine:init", () => {
if (hl?.pdfPage >= 0) { if (hl?.pdfPage >= 0) {
this.renderer?.removeRectAnnotation?.(id); this.renderer?.removeRectAnnotation?.(id);
} else if (hl?.cfi) { } else if (hl?.cfi) {
this.view?.deleteAnnotation({ value: hl.cfi }); // Delete with the value the overlay was added by: device-synced
// highlights paint a synthesized range, not the stored point CFI.
this.view?.deleteAnnotation({
value: hl.renderCfi || hl.cfi,
});
} }
this.hideSelectionPopover(); this.hideSelectionPopover();
} catch (_e) { } catch (_e) {