Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbdddc095e | ||
|
|
5a0d9e0e17 | ||
|
|
19619ef817 | ||
|
|
a28ae5c88d | ||
|
|
1c209408cc | ||
|
|
93f020f2b5 | ||
|
|
a3ecac3507 | ||
|
|
db3b5b3d62 | ||
|
|
f69e659d94 | ||
|
|
dbeefb395c | ||
|
|
8acaeddda5 | ||
|
|
361fdaab39 | ||
|
|
4d1fb05e49 |
No files matched your search
@@ -250,6 +250,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
registerWebSocketRoutes(cfg)
|
registerWebSocketRoutes(cfg)
|
||||||
registerFrontendRoutes(cfg)
|
registerFrontendRoutes(cfg)
|
||||||
registerDocumentationRoutes(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")
|
e.Static("/static", "web/static")
|
||||||
|
|
||||||
// Catch-all 404 handler - must be last
|
// Catch-all 404 handler - must be last
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="reader-popover-btn"
|
class="reader-popover-btn"
|
||||||
title="Adjust highlight extent"
|
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>
|
<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>
|
</button>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func Reader(user User, metadata ReaderMetadata) 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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -210,8 +210,20 @@ Alpine.data("bookDetail", () => {
|
|||||||
headers: { Authorization: getAuthHeader() },
|
headers: { Authorization: getAuthHeader() },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const notes = noteResp.ok ? await noteResp.json() : [];
|
// A partial failure must not read as "no annotations": a failed
|
||||||
const highlights = hlResp.ok ? await hlResp.json() : [];
|
// 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);
|
const built = buildAnnotationRows(notes, highlights);
|
||||||
this.bannerRows = built.rows;
|
this.bannerRows = built.rows;
|
||||||
this.bannerSources = built.sources;
|
this.bannerSources = built.sources;
|
||||||
|
|||||||
+531
-1
@@ -24,6 +24,12 @@ const HIGHLIGHT_COLORS = [
|
|||||||
// Alpine reactivity never wraps it and revisiting pages is instant.
|
// Alpine reactivity never wraps it and revisiting pages is instant.
|
||||||
const thumbCache = new Map<number, HTMLCanvasElement>();
|
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}`;
|
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
|
||||||
|
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
@@ -496,6 +502,27 @@ document.addEventListener("alpine:init", () => {
|
|||||||
color: string;
|
color: string;
|
||||||
},
|
},
|
||||||
adjustCancelOnRelocate: null as null | (() => void),
|
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,
|
pdfSelDoc: null as any,
|
||||||
progressText: "",
|
progressText: "",
|
||||||
progressLabel: "",
|
progressLabel: "",
|
||||||
@@ -691,7 +718,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
"show-rect-annotation",
|
"show-rect-annotation",
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
const { key, clientX, clientY } = e.detail;
|
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;
|
if (!h) return;
|
||||||
this.openSelectionPopover({
|
this.openSelectionPopover({
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
@@ -718,6 +748,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.view.addEventListener("load", (e: any) => {
|
this.view.addEventListener("load", (e: any) => {
|
||||||
const { doc, index } = e.detail;
|
const { doc, index } = e.detail;
|
||||||
|
if (doc && typeof index === "number")
|
||||||
|
pdfFrameDocs.set(doc, index);
|
||||||
this.currentDoc = doc;
|
this.currentDoc = doc;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
@@ -1521,6 +1553,426 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.adjustContext = null;
|
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) {
|
scheduleSelectionPopover(doc: any, index: number) {
|
||||||
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||||
this.selPopTimer = setTimeout(() => {
|
this.selPopTimer = setTimeout(() => {
|
||||||
@@ -1617,6 +2069,84 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||||
if (!text) return;
|
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
|
// Denominator: the element whose post-transform screen rect IS
|
||||||
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
||||||
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
||||||
|
|||||||
@@ -972,6 +972,29 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
scale: 1.15;
|
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 {
|
.reader-note-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|||||||
@@ -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: }
|
*,: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
|
||||||
Reference in new issue
Block a user