Compare commits

..
13 Commits
Author SHA1 Message Date
John O'Keefe cbdddc095e fix(web): PDF adjust — remove the leftover line-band filter from the endpoint drag
Release / build-and-push (push) Successful in 2m39s
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
2026-10-11 20:44:32 -04:00
John O'Keefe 5a0d9e0e17 feat(web): endpoint handles drag vertically — full 2D tip-aimed targeting
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).

The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).

Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
2026-10-11 18:15:34 -04:00
John O'Keefe 19619ef817 fix(web): raise the adjust teardrops — tips rest above the glyph tops
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
2026-10-11 17:57:03 -04:00
John O'Keefe a28ae5c88d feat(web): adjust handles float above the text with tips marking the boundary
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.

Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
2026-10-11 17:25:58 -04:00
John O'Keefe 1c209408cc fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:

1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
   dot's pointermove listener passed raw e.clientX/clientY PIXELS.
   Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
   picks the bottom-right-most glyph — the extent leaped to the page's
   end and never moved again (stuck). Engine-independent: failed
   identically on Chrome/Brave/Firefox desktops; my synthetic tests
   masked it by passing pre-converted fractions. handleMove now maps
   host px → fractions internally (one contract).

2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
   rect with the host iframe rect. Whether that combination is valid
   depends on the engine's cross-document getBoundingClientRect
   semantics. Replaced with the page WRAPPER element (the iframe's
   parent — the same box the fork's rect-annotation overlay fills),
   measured in a single host context. Handles now align with the
   painted highlights by construction.

3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
   gap tall). Dedupe at a full box height ate the line-below char in
   the same x-column (silent character loss); clustering at full box
   height never split lines (merged 0.45-tall "line" boxes). Dedupe
   now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
   the char's own height (inter-line gap ≈ 1.0× breaks, baseline
   variance ≈ 0.1× stays).

Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
2026-10-11 13:01:16 -04:00
John O'Keefe 93f020f2b5 fix(web): PDF adjust handles — create once, reposition only
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.

The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.

Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
2026-10-11 11:21:51 -04:00
John O'Keefe a3ecac3507 fix(server): Cache-Control no-cache on /static — heuristic caching served stale bundles
The static handler sent no Cache-Control, so browsers fell back to
heuristic caching (10% of age since Last-Modified) and kept serving a
stale reader.js for hours after a deploy — fixed web bugs reported as
"not fixed". no-cache forces revalidation every load; the filesystem
Last-Modified/If-Modified-Since pair keeps unchanged loads cheap.

Found while closing out the PDF adjust v2 session: the user's Chrome
was running a pre-endpoint bundle, which read as "the web has not
been fixed".
2026-10-11 09:50:42 -04:00
John O'Keefe db3b5b3d62 fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design 2026-10-10 23:19:35 -04:00
John O'Keefe f69e659d94 feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
2026-10-10 18:25:29 -04:00
John O'Keefe dbeefb395c fix(web): PDF adjust text recompute — overlap-based glyph dedupe
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
2026-10-10 14:25:48 -04:00
John O'Keefe 8acaeddda5 fix(web): detail banner — a failed annotation fetch must not wipe its rows
fetchBannerAnnotations published the rebuilt rows unconditionally, with
a failed endpoint's list defaulted to empty — so one failing request
(a single handler 500, an auth blip on one call) silently wiped that
annotation kind from the banner while the other kind stayed, and the
empty state could claim "No notes or highlights on this book yet" over
real data. A full outage was already safe (the thrown fetch takes the
catch path, which keeps previous rows and toasts) — the partial case
was the hole, spotted while mirroring the app's fetch-wipe fix
(bookhoard-app 07bbb4c) onto the web.

Both responses must now succeed before the rows are published;
otherwise the previous rows stay and the next banner open retries
(bannerLoaded only sets on success). Verified on the emulator: banner
renders 3 rows, a full outage keeps them with bannerError recorded,
and a post-recovery refetch re-renders from live data.
2026-10-10 13:56:32 -04:00
John O'Keefe 361fdaab39 fix(web): PDF rect-edge adjust — a start-edge drag pins the far edge
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
2026-10-10 11:47:41 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
2026-10-10 11:22:09 -04:00
7 changed files with 586 additions and 6 deletions

No files matched your search

+15
View File
@@ -250,6 +250,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg)
// Static bundles are served unhashed (reader.js, index.js), so the
// browser must revalidate on every load — with no Cache-Control it
// fell back to heuristic caching and served stale bundles for hours
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
// still allows cheap If-Modified-Since 304 revalidation.
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
}
return next(c)
}
})
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
+1 -1
View File
@@ -166,7 +166,7 @@ templ Reader(user User, metadata ReaderMetadata) {
type="button"
class="reader-popover-btn"
title="Adjust highlight extent"
@click="startAdjust()"
@click="startAnyAdjust()"
>
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14"></path></svg>
</button>
+1 -1
View File
@@ -123,7 +123,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.noteOpen\n\t\t\t\t\t\t\t? (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? (selectionPopover.color = c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges(true)))\n\t\t\t\t\t\t\t: (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? createHighlight(c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges()))\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" 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> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" 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=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.noteOpen\n\t\t\t\t\t\t\t? (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? (selectionPopover.color = c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges(true)))\n\t\t\t\t\t\t\t: (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? createHighlight(c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges()))\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" 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> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAnyAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" 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=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+14 -2
View File
@@ -210,8 +210,20 @@ Alpine.data("bookDetail", () => {
headers: { Authorization: getAuthHeader() },
}),
]);
const notes = noteResp.ok ? await noteResp.json() : [];
const highlights = hlResp.ok ? await hlResp.json() : [];
// A partial failure must not read as "no annotations": a failed
// endpoint used to be published as an empty list, silently wiping
// that kind from the banner while the other kind stayed (found
// auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c).
// Keep the previous rows; the next banner open retries.
if (!noteResp.ok || !hlResp.ok) {
console.warn(
`banner annotation load failed ` +
`(notes ${noteResp.status}, highlights ${hlResp.status}) — keeping previous`,
);
return;
}
const notes = await noteResp.json();
const highlights = await hlResp.json();
const built = buildAnnotationRows(notes, highlights);
this.bannerRows = built.rows;
this.bannerSources = built.sources;
+531 -1
View File
@@ -24,6 +24,12 @@ const HIGHLIGHT_COLORS = [
// Alpine reactivity never wraps it and revisiting pages is instant.
const thumbCache = new Map<number, HTMLCanvasElement>();
// Fixed-layout page iframe → its page index. The fixed-layout renderer's
// getContents() returns {doc} WITHOUT an index (unlike the reflowable
// renderer), so the rect-edge adjust maps a page index back to its iframe
// doc through this registry, filled by the view's load events.
const pdfFrameDocs = new WeakMap<object, number>();
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
const FONT_MAP: Record<string, string> = {
@@ -496,6 +502,27 @@ document.addEventListener("alpine:init", () => {
color: string;
},
adjustCancelOnRelocate: null as null | (() => void),
/** PDF rect-edge adjust session (fixed-layout highlights): the working
* copy of the highlight's rects (page fractions) plus the original
* for cancel. Null outside adjust mode. */
pdfAdjust: null as null | {
id: string;
index: number;
page: number;
color: string;
rects: number[][];
original: number[][];
/** The working extent as a live DOM range: handles re-anchor its
* boundaries via caret probes, and rects rebuild from its line
* boxes on every move. */
range: Range;
/** True once finalize handed off to saveHighlightChanges — cancel
* must not restore the original paint over the saved new one. */
committed: boolean;
},
pdfAdjustRaf: 0,
pdfAdjustOnRelocate: null as null | (() => void),
pdfAdjustOnZoom: null as null | (() => void),
pdfSelDoc: null as any,
progressText: "",
progressLabel: "",
@@ -691,7 +718,10 @@ document.addEventListener("alpine:init", () => {
"show-rect-annotation",
(e: any) => {
const { key, clientX, clientY } = e.detail;
const h = this.highlightItems.find((x) => x.id === key);
// A tap anywhere on the page during rect adjust must not pop
// the editor mid-session (page turn is the cancel gesture).
if (this.pdfAdjust) return;
const h = this.highlightItems.find((x: any) => x.id === key);
if (!h) return;
this.openSelectionPopover({
mode: "edit",
@@ -718,6 +748,8 @@ document.addEventListener("alpine:init", () => {
}
this.view.addEventListener("load", (e: any) => {
const { doc, index } = e.detail;
if (doc && typeof index === "number")
pdfFrameDocs.set(doc, index);
this.currentDoc = doc;
const link = doc.createElement("link");
link.rel = "stylesheet";
@@ -1521,6 +1553,426 @@ document.addEventListener("alpine:init", () => {
}
this.adjustContext = null;
},
// ----- PDF highlight extent adjustment (v2: endpoint handles) -----
/** Format router for the popover's Adjust button: PDF highlights
* adjust with two endpoint handles, reflowable ones with range
* handles. */
startAnyAdjust() {
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
else this.startAdjust();
},
/** Page→host mapping for a fixed-layout index. Basis: the page's
* WRAPPER element (the iframe's parent — the same box the fork's
* own rect-annotation overlay fills with percentage-positioned
* children). Measuring it is a single same-context host measurement:
* the previous version combined the iframe-internal canvas rect
* with the host iframe rect, and whether that mix is valid depends
* on which coordinate space a given engine returns for
* cross-document getBoundingClientRect — on desktop Brave it
* mismatched, so drags re-anchored "several lines down" and stuck.
* The wrapper is also what the highlights are painted against, so
* handle positions align with the paint by construction. */
pdfPageMapping(index: number) {
// The fixed-layout renderer's getContents() carries no index — map
// through the load-event registry, keeping only live frames.
const contents = this.renderer?.getContents?.() ?? [];
const doc = contents
.map((c: any) => c.doc)
.find(
(d: any) =>
d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement,
);
if (!doc) return null;
const frame = doc.defaultView?.frameElement as HTMLElement | null;
const wrapper = frame?.parentElement;
const wr = wrapper?.getBoundingClientRect();
if (!wr || !wr.width || !wr.height) return null;
const left = wr.left;
const top = wr.top;
const width = wr.width;
const height = wr.height;
return {
doc: doc as Document,
toHost(fx: number, fy: number) {
return { x: left + fx * width, y: top + fy * height };
},
toFraction(clientX: number, clientY: number) {
return {
fx: (clientX - left) / width,
fy: (clientY - top) / height,
};
},
};
},
/** Character glyph boxes (page fractions) in reading order, with
* dedupe: pdf.js re-renders (zoom settle, scale updates) append a
* second span set into the same .textLayer without clearing — char
* boxes come back duplicated, and the copies can sit SUB-PIXEL
* SHIFTED (a doubled-letter text recompute once slipped through an
* exact-position key). Dedupe by overlap instead: a candidate is a
* duplicate when a kept glyph on the same line overlaps more than
* half the narrower glyph. Distinct glyphs never reach that. */
pdfCharBoxes(doc: Document): {
x0: number;
x1: number;
y0: number;
y1: number;
ch: string;
}[] {
const canvas =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvas?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) return [];
const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] =
[];
const range = doc.createRange();
const spans = doc.querySelectorAll(".textLayer span");
spans.forEach((span) => {
const node = span.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.nodeValue ?? "";
for (let i = 0; i < text.length; i++) {
if (/\s/.test(text[i])) continue;
range.setStart(node, i);
range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
if (!r.width && !r.height) continue;
const box = {
x0: (r.left - dr.left) / dr.width,
x1: (r.right - dr.left) / dr.width,
y0: (r.top - dr.top) / dr.height,
y1: (r.bottom - dr.top) / dr.height,
ch: text[i],
};
if (box.x1 <= 0 || box.x0 >= 1 || box.y1 <= 0 || box.y0 >= 1) continue;
const w = box.x1 - box.x0;
const h = box.y1 - box.y0;
const dup = out.some((k) => {
// A duplicate is the SAME glyph re-rendered at a drifted
// position: y within 0.75 of the SHORTER box (per-char boxes
// are LINE-HEIGHT boxes — a full-height tolerance let a
// line-1 char eat the line-2 char beneath it in the same
// x-column), x overlapping more than half the narrower box.
// The staircase of offset re-render copies steps by ~0.004 —
// well inside this tolerance; real inter-line gaps (~0.0196)
// are outside it.
const kh = k.y1 - k.y0;
const tolY = Math.min(kh, h) * 0.75;
if (Math.abs(k.y0 - box.y0) > tolY) return false;
const kw = k.x1 - k.x0;
const overlap = Math.min(k.x1, box.x1) - Math.max(k.x0, box.x0);
return overlap > Math.min(kw, w) * 0.5;
});
if (dup) continue;
out.push(box);
}
});
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
return out;
},
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
* build the page's character table (once — every drag reads the
* cached table in pure fraction space; no DOM reads, no
* coordinate-space hazards — portable to any browser engine), map
* the stored rects to a word... char range, put the two endpoint
* handles up, and arm the cancel paths. */
startPdfAdjust() {
const p = this.selectionPopover;
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
const map = this.pdfPageMapping(p.pdfPage);
if (!map) return;
const chars = this.pdfCharBoxes(map.doc);
if (!chars.length) return;
// The extent's char range: chars whose centers sit inside the
// stored rects (creation- and adjust-made extents both cover their
// own glyphs).
let startI = -1;
let endI = -1;
chars.forEach((c, i) => {
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
if (
p.pdfRects.some(
(r) =>
cx >= r[0] &&
cx <= r[0] + r[2] &&
cy >= r[1] &&
cy <= r[1] + r[3],
)
) {
if (startI < 0) startI = i;
endI = i;
}
});
if (startI < 0) return;
this.pdfAdjust = {
id: p.id,
index: p.pdfPage,
page: p.pdfPage,
color: p.color,
rects: p.pdfRects.map((r) => [...r]),
original: p.pdfRects.map((r) => [...r]),
chars,
startI,
endI,
committed: false,
};
this.hideSelectionPopover();
const layer = this.ensureAdjustLayer();
layer.innerHTML = "";
this.renderPdfAdjustHandles();
// A page turn ends the session safely — the preview is discarded,
// nothing saved (same contract as the reflowable adjust).
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
once: true,
});
// Zoom re-renders the page — re-derive the handles from live rects.
this.pdfAdjustOnZoom = () => this.renderPdfAdjustHandles();
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
},
/** (Re)draw the two endpoint handles, positioned through the live
* page mapping. The dots are created ONCE per session — every later
* call only repositions the existing elements (an innerHTML rebuild
* mid-drag destroys the dot being dragged; it loses pointer capture
* and the drag dies silently — user report). */
renderPdfAdjustHandles() {
const ctx = this.pdfAdjust;
if (!ctx) return;
const layer = document.getElementById("adjust-handles");
if (!layer) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const first = ctx.rects[0];
const last = ctx.rects[ctx.rects.length - 1];
if (!first || !last) return;
// Start dot floating ABOVE the first rect's top-left corner, end
// dot above the last rect's top-right corner — the teardrop tip
// points down at the boundary so the highlighted text underneath
// stays readable (user request: the handle covered the text).
const pts: Array<{ which: "start" | "end"; fx: number; fy: number }> = [
{ which: "start", fx: first[0], fy: first[1] },
{ which: "end", fx: last[0] + last[2], fy: last[1] },
];
for (const pt of pts) {
let dot = layer.querySelector<HTMLDivElement>(
`.adjust-handle[data-which="${pt.which}"]`,
);
if (!dot) {
dot = document.createElement("div");
dot.className = "adjust-handle";
dot.dataset.which = pt.which;
const which = pt.which;
// Pointer events (mouse + touch + pen); the page's own touch
// handlers are not engaged because the dots live in the HOST
// document, above the iframe.
dot.addEventListener("pointerdown", (e) => {
e.preventDefault();
try {
dot!.setPointerCapture(e.pointerId);
} catch {}
dot!.dataset.dragging = "1";
});
dot.addEventListener("pointermove", (e) => {
if (!dot!.dataset.dragging) return;
// Aim at the TIP (finger + ~15px, the body-center-to-tip
// offset): the body floats above the text, the tip shows
// where the boundary actually is.
this.pdfAdjustHandleMove(which, e.clientX, e.clientY + 15);
});
dot.addEventListener("pointerup", (e) => {
if (!dot!.dataset.dragging) return;
delete dot!.dataset.dragging;
try {
dot!.releasePointerCapture?.(e.pointerId);
} catch {}
this.pdfAdjustRelease();
});
dot.addEventListener("pointercancel", () => {
delete dot!.dataset.dragging;
});
layer.appendChild(dot);
}
const host = map.toHost(pt.fx, pt.fy);
// The teardrop's rotated tip is its bottom point: hang the box
// ABOVE the boundary corner, tip resting ~4px above the glyph
// tops — a clear gap so the tip never covers letters (Android
// handle spacing).
dot.style.left = `${host.x - 13}px`;
dot.style.top = `${host.y - 30}px`;
}
},
/** Line boxes (page fractions) of a contiguous char-table span:
* cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT
* boxes (~one line gap tall), so the break threshold must sit
* clearly below the inter-line gap yet above intra-line baseline
* variance — 0.6 of the char's own height does that (gap ≈ 1.0 ×
* height breaks; variance ≈ 0.1 × height stays). */
charSpanRects(
chars: { x0: number; x1: number; y0: number; y1: number }[],
): number[][] {
const rects: number[][] = [];
let line: typeof chars = [];
let lastCy = -9;
for (const c of chars) {
const cy = (c.y0 + c.y1) / 2;
const tol = (c.y1 - c.y0) * 0.6;
if (line.length && Math.abs(cy - lastCy) > tol) {
const x0 = Math.min(...line.map((w) => w.x0));
const y0 = Math.min(...line.map((w) => w.y0));
const x1 = Math.max(...line.map((w) => w.x1));
const y1 = Math.max(...line.map((w) => w.y1));
rects.push([x0, y0, x1 - x0, y1 - y0]);
line = [];
}
line.push(c);
lastCy = cy;
}
if (line.length) {
const x0 = Math.min(...line.map((w) => w.x0));
const y0 = Math.min(...line.map((w) => w.y0));
const x1 = Math.max(...line.map((w) => w.x1));
const y1 = Math.max(...line.map((w) => w.y1));
rects.push([x0, y0, x1 - x0, y1 - y0]);
}
return rects;
},
/** Endpoint drag: the finger (HOST px) maps to page fractions via
* the live mapping, then re-anchors the grabbed end at the nearest
* glyph in the cached char table — character-precise (a trailing
* period or line-break hyphen can be taken or shed), crossing
* lines freely — and the rects rebuild from the new char span as
* per-line unions. Pure fraction space after the mapping: no
* caretRangeFromPoint, no coordinate-space conversion, works on
* any browser engine (Firefox included). */
pdfAdjustHandleMove(
which: "start" | "end",
clientX: number,
clientY: number,
) {
const ctx = this.pdfAdjust;
if (!ctx) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const { fx, fy } = map.toFraction(clientX, clientY);
const chars = ctx.chars;
if (!chars.length) return;
// Full 2D nearest glyph: the tip crosses lines freely (up/down)
// and moves along them (side to side). No line-band pin — the
// lifted handle's finger sits above the text, and a y-band filter
// here made vertical drags impossible (user report). Sliver rects
// stay impossible: the extent is a contiguous char span and the
// rects are its per-line unions.
let best = 0;
let bestD = Infinity;
for (let i = 0; i < chars.length; i++) {
const c = chars[i];
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
if (d < bestD) {
bestD = d;
best = i;
}
}
let s = ctx.startI;
let e = ctx.endI;
if (which === "start") s = Math.min(best, e);
else e = Math.max(best, s);
if (s > e) return;
ctx.startI = s;
ctx.endI = e;
ctx.rects = this.charSpanRects(chars.slice(s, e + 1));
this.schedulePdfAdjustPreview();
},
/** Preview scheduling: coalesce pointermove bursts into one overlay
* repaint per frame. The preview replaces the highlight's OWN
* overlay entry (addRectAnnotation is replace-by-key), so there is
* never a double paint and the save re-adds the same key. */
schedulePdfAdjustPreview() {
if (this.pdfAdjustRaf) return;
this.pdfAdjustRaf = requestAnimationFrame(() => {
this.pdfAdjustRaf = 0;
const ctx = this.pdfAdjust;
if (!ctx) return;
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.rects,
color: ctx.color,
});
this.renderPdfAdjustHandles();
});
},
/** Release: the extent's chars ARE the text — join them (line gaps
* re-insert spaces, matching the creation path's normalized
* selection text) and save through the popover's own PUT path. */
async pdfAdjustRelease() {
const ctx = this.pdfAdjust;
if (!ctx) return;
const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1);
let text = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars) {
if (prev) {
const pc = (prev.y0 + prev.y1) / 2;
const cc = (c.y0 + c.y1) / 2;
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
if (Math.abs(cc - pc) > half) text += " ";
else if (c.x0 - prev.x1 > 0.004) text += " ";
}
text += c.ch;
prev = c;
}
text = text.replace(/\s+/g, " ").trim();
const rects = ctx.rects;
const { id, page } = ctx;
ctx.committed = true;
this.cancelPdfAdjust();
if (!text) {
showToast("Highlights can't be empty", "error");
return;
}
const p = this.selectionPopover;
p.id = id;
p.pdfPage = page;
p.pdfRects = rects;
p.text = text;
await this.saveHighlightChanges();
},
/** Leave rect adjust: restore the stored paint unless the adjustment
* was committed (cancel runs after finalize too), and drop the mode. */
cancelPdfAdjust() {
const ctx = this.pdfAdjust;
if (!ctx) return;
if (!ctx.committed) {
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.original,
color: ctx.color,
});
}
this.pdfAdjust = null;
if (this.pdfAdjustRaf) {
cancelAnimationFrame(this.pdfAdjustRaf);
this.pdfAdjustRaf = 0;
}
if (this.pdfAdjustOnRelocate) {
this.renderer?.removeEventListener?.(
"relocate",
this.pdfAdjustOnRelocate,
);
this.pdfAdjustOnRelocate = null;
}
if (this.pdfAdjustOnZoom) {
this.renderer?.removeEventListener?.("zoom", this.pdfAdjustOnZoom);
this.pdfAdjustOnZoom = null;
}
document.getElementById("adjust-handles")?.remove();
},
scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {
@@ -1617,6 +2069,84 @@ document.addEventListener("alpine:init", () => {
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Adjust re-selection (v2): a selection made during a rect-edge
// session REPLACES the working extent — any lines, partial lines
// (the exact gap the handles alone could not express from stale
// geometry). Chars inside the selection's line boxes become the
// new extent; text reads from them; saved immediately: the
// handles' release-to-save parity.
if (this.pdfAdjust) {
const canvasRect =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvasRect?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) return;
const selRects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) selRects.push([x, y, w, h]);
}
if (!selRects.length) return;
const chars = this.pdfAdjust.chars;
const picked: number[] = [];
chars.forEach((c, i) => {
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
if (
selRects.some(
(r) =>
cx >= r[0] &&
cx <= r[0] + r[2] &&
cy >= r[1] &&
cy <= r[1] + r[3],
)
)
picked.push(i);
});
if (!picked.length) {
showToast("Highlights can't be empty", "error");
return;
}
const session = this.pdfAdjust;
const startI = picked[0];
const endI = picked[picked.length - 1];
const extentRects = this.charSpanRects(chars.slice(startI, endI + 1));
let t = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars.slice(startI, endI + 1)) {
if (prev) {
const pc = (prev.y0 + prev.y1) / 2;
const cc = (c.y0 + c.y1) / 2;
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
if (Math.abs(cc - pc) > half) t += " ";
else if (c.x0 - prev.x1 > 0.004) t += " ";
}
t += c.ch;
prev = c;
}
t = t.replace(/\s+/g, " ").trim();
if (!t) {
showToast("Highlights can't be empty", "error");
return;
}
session.committed = true;
this.cancelPdfAdjust();
const p = this.selectionPopover;
p.id = session.id;
p.pdfPage = session.page;
p.pdfRects = extentRects;
p.text = t;
this.saveHighlightChanges();
try {
sel.removeAllRanges();
} catch {
/* doc may be gone */
}
return;
}
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect
+23
View File
@@ -972,6 +972,29 @@
cursor: grabbing;
scale: 1.15;
}
/* PDF rect-edge adjust grips (fixed-layout highlights): short edge bars
centered on each rect's edge midpoint. Vertical edges drag in x,
horizontal edges in y — resize only, one rect per drag. */
.adjust-grip {
position: fixed;
width: 10px;
height: 30px;
border-radius: 5px;
background: #3b82f6;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
pointer-events: auto;
cursor: ew-resize;
touch-action: none;
}
.adjust-grip[data-dir="h"] {
width: 30px;
height: 10px;
cursor: ns-resize;
}
.adjust-grip[data-dragging] {
scale: 1.1;
}
.reader-note-input {
width: 100%;
resize: vertical;
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated