Skip to content

Commit 41ea032

Browse files
committed
Updated phone UI to fix crashes on iPhone; misc improvements to phone UI
1 parent 77ecb83 commit 41ea032

12 files changed

Lines changed: 286 additions & 195 deletions

File tree

js/containers/imageContainer.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { ca } from '../canvasAdapter.js';
2020
// This cap is an arbitrarily high number, and hitting indicates a logic error.
2121
const MAX_SKIPPED_RETRY = 32;
2222

23+
// Above the largest size any surface displays a thumbnail at, so CSS only ever downscales and cells stay crisp on high-DPI screens.
24+
const THUMBNAIL_W = 300;
25+
2326
/** @type {?boolean} */
2427
let _sabCapability = null;
2528
/**
@@ -163,6 +166,12 @@ export class ImageStore {
163166
*/
164167
thumbnails = [];
165168

169+
/**
170+
* Keyed by the Blob rather than the page index because reordering pages permutes `thumbnails`, leaving an index-keyed URL pointing at another page's image.
171+
* @type {Map<Blob, string>}
172+
*/
173+
thumbnailUrls = new Map();
174+
166175
/**
167176
* Estimated cost of re-rendering each display slot (ms), from its last render with one-time work excluded.
168177
* @type {Array<number|undefined>}
@@ -600,6 +609,23 @@ export class ImageStore {
600609
return p;
601610
};
602611

612+
/**
613+
* A single object URL for page `n`'s thumbnail, created once and reused by every display surface.
614+
* @param {number} n
615+
* @returns {Promise<?string>} The URL, or null if the page cannot be rendered.
616+
*/
617+
thumbnailUrl = async (n) => {
618+
const blob = await this.renderThumbnail(n, THUMBNAIL_W);
619+
if (!blob) return null;
620+
// Two `<img>` elements pointing at one URL share a single decode.
621+
let url = this.thumbnailUrls.get(blob);
622+
if (!url) {
623+
url = URL.createObjectURL(blob);
624+
this.thumbnailUrls.set(blob, url);
625+
}
626+
return url;
627+
};
628+
603629
/**
604630
* @param {number} n
605631
* @param {ImagePropertiesRequest} [props]
@@ -643,6 +669,8 @@ export class ImageStore {
643669
};
644670

645671
clear = () => {
672+
for (const url of this.thumbnailUrls.values()) URL.revokeObjectURL(url);
673+
this.thumbnailUrls.clear();
646674
this.nativeSrc = [];
647675
this.native = [];
648676
this.binary = [];

scribe-ui/basic-viewer/pdf-viewer.js

Lines changed: 29 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { createCommentsPanel } from '../js/controls/commentsPanel.js';
1818
import {
1919
createHighlightTool, createNoteTool, createDropZone, openDocumentFromFile, createRedactTool, NOTE_TOOL_SVG,
2020
} from '../js/controls/tools.js';
21-
import { redactWords } from '../js/viewerRedactions.js';
2221
import { filesFromDropEvent } from '../js/dragAndDrop.js';
2322
import { mergePdfs } from '../../js/export/pdf/mergePdfs.js';
2423
import { concatOutlines, outlineSplitSegments } from '../../js/objects/outlineObjects.js';
@@ -122,7 +121,7 @@ class ScribePDFViewer {
122121
* Defaults to the `(pointer: coarse)` media query; pass explicitly to override.
123122
* @param {boolean} [options.edit=true] - Enable editing: page ops (reorder/delete/rotate/insert), text recognition,
124123
* redaction, the Export/Combine/Split app-menu actions, and the dark-mode toggle. Pass `false` for a lean read-only viewer.
125-
* @param {boolean} [options.redact=edit] - Enable redaction marks (context-menu "Redact" and the find bar's "Redact all").
124+
* @param {boolean} [options.redact=edit] - Enable redaction marks, reached through the context menu's "Redact".
126125
* Defaults to `edit`. Pass `false` to keep editing on but redaction off. Ignored when `edit` is false.
127126
* @param {ScribeViewer} [options.scribe] - Attach to an existing `ScribeViewer` instance instead
128127
* of creating a new one. Use to share state with an already-instantiated viewer.
@@ -418,7 +417,7 @@ class ScribePDFViewer {
418417
}
419418
if (DEBUG_MENU) {
420419
import('../js/controls/debugMenu.js')
421-
.then(({ installDebugMenu }) => installDebugMenu(appMenu, this.scribe))
420+
.then(({ installDebugMenu }) => installDebugMenu(appMenu, this.scribe, (files) => this.openFiles(files)))
422421
.catch((err) => console.error('Failed to load the debug menu:', err));
423422
}
424423
// Style the otherwise-empty start zone as a left-aligned flex row, with an 8px inset mirroring the end zone's, so the menu button sits at the left edge.
@@ -802,12 +801,9 @@ class ScribePDFViewer {
802801
redactCueShown = true;
803802
this._showToast('Marked for redaction — the content is removed when you export.');
804803
};
805-
// No toolbar button: redaction is reached through the context menu and the find bar's "Redact all".
806-
// Building the tool sets `scribe._redactEnabled`, the context-menu gate, and installs region box-draw.
807804
this._redactTool = createRedactTool(this.scribe, this.pdfViewerElem, { onMark });
808805
this.scribe._onRedactMark = onMark;
809806
this._teardownCallbacks.push(this._redactTool.installBehaviors());
810-
this._addRedactAllToSearchBar();
811807
}
812808
}
813809
}
@@ -1725,9 +1721,8 @@ class ScribePDFViewer {
17251721
if (af && af.isDefaultFit && this.scribe.scrollContainer && af.zoom > 0
17261722
&& Math.abs(this.scribe.zoomLevel - af.zoom) / af.zoom < 0.05) {
17271723
const sc = this.scribe.scrollContainer;
1728-
// Mirrors _installFit's default branches (height fit with its 100+50 margins, width when it overflows).
17291724
const hZoom = (sc.clientHeight - 150) / af.imgDims.height;
1730-
const widthMode = hZoom * af.imgDims.width > sc.clientWidth;
1725+
const widthMode = this._phoneChrome || hZoom * af.imgDims.width > sc.clientWidth;
17311726
if (widthMode || af.widthMode) {
17321727
const target = widthMode ? sc.clientWidth / af.imgDims.width : hZoom;
17331728
if (target > 0 && Math.abs(target - this.scribe.zoomLevel) / this.scribe.zoomLevel > 0.01) {
@@ -1875,17 +1870,17 @@ class ScribePDFViewer {
18751870
roomRevert.addEventListener('click', () => {
18761871
const doc = this.scribe.doc;
18771872
if (!this._roomEditing || !doc || this._roomEditBaseline < 0) return;
1878-
// Captured before the unwind so the slide can animate from the pre-revert grid.
1873+
const baseline = this._roomEditBaseline;
1874+
// Leave Edit before the unwind, so the badges lose their selected colour and disappear in one style recalc.
1875+
this._setRoomEditing(false);
1876+
// Captured after leaving Edit, so the slide starts in the same layout the rebuilt grid lands in.
18791877
const playSlide = this._thumbnailPanel ? this._thumbnailPanel.beginStructureSlide() : null;
18801878
// Model-level undo for all but the last step, then one viewer-level undo so the view rebuilds and the refresh callbacks fire once.
1881-
while (doc.history.undoStack.length > this._roomEditBaseline + 1) {
1879+
while (doc.history.undoStack.length > baseline + 1) {
18821880
if (!doc.undo()) break;
18831881
}
1884-
if (doc.history.undoStack.length > this._roomEditBaseline) this.scribe.undo();
1882+
if (doc.history.undoStack.length > baseline) this.scribe.undo();
18851883
if (playSlide) playSlide();
1886-
// The selection marked pages by index, and the unwind made those indices stale.
1887-
if (this._thumbnailPanel) this._thumbnailPanel.clearSelection();
1888-
this._setRoomEditing(false);
18891884
});
18901885
this._roomRevertBtn = roomRevert;
18911886
const roomDone = document.createElement('button');
@@ -1955,7 +1950,14 @@ class ScribePDFViewer {
19551950
const commit = down > Math.min(140, p.travel * 0.25);
19561951
if (p.morph) {
19571952
const morph = this._pagesMorph;
1958-
if (morph) morph.settle(!commit, (stillOpen) => { if (!stillOpen) this._roomOpen = false; });
1953+
// The close may have flipped the covered strip to the browsed rows, and a parked strip does not glide back on its own.
1954+
if (morph) {
1955+
morph.settle(!commit, (stillOpen) => {
1956+
if (stillOpen) return;
1957+
this._roomOpen = false;
1958+
if (this._companionStrip) this._companionStrip.settle();
1959+
});
1960+
}
19591961
return;
19601962
}
19611963
// Plain-slide release: flush the dragged position so the snap animates from the finger's release point.
@@ -2329,6 +2331,12 @@ class ScribePDFViewer {
23292331
* @param {boolean} [instant=false] - Skip the slide-out, for mode flips mid-resize.
23302332
*/
23312333
_closePagesRoom(instant = false) {
2334+
if (!instant && this._pagesMorph && this._pagesMorph.isActive()) {
2335+
this._setRoomEditing(false);
2336+
this._roomOpen = false;
2337+
this._pagesMorph.settle(false);
2338+
return;
2339+
}
23322340
// Abort before the open-guard below: a close during a live pull arrives with `_roomOpen` still false and would otherwise leave the morph standing.
23332341
if (this._pagesMorph) this._pagesMorph.abort();
23342342
this._setRoomEditing(false);
@@ -2339,7 +2347,7 @@ class ScribePDFViewer {
23392347
if (!instant && this._pagesMorph
23402348
&& !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
23412349
&& this._pagesMorph.beginClose()) {
2342-
this._pagesMorph.settle(false);
2350+
this._pagesMorph.settle(false, () => { if (this._companionStrip) this._companionStrip.settle(); });
23432351
return;
23442352
}
23452353
if (instant) {
@@ -2474,7 +2482,7 @@ class ScribePDFViewer {
24742482
* Install a `setInitialPositionZoom` implementation on `ScribeViewer` based on the requested fit mode.
24752483
* @param {FitMode} fitMode
24762484
* @param {boolean} [isDefaultFit=false] - `fitMode` is the constructor default, not a caller choice.
2477-
* Only then may the narrow-viewer width-fit override apply.
2485+
* Only then may the width-fit override apply.
24782486
*/
24792487
_installFit(fitMode, isDefaultFit = false) {
24802488
this.scribe.setInitialPositionZoom = (imgDims) => {
@@ -2483,10 +2491,10 @@ class ScribePDFViewer {
24832491
const stageW = sc.clientWidth;
24842492
const stageH = sc.clientHeight;
24852493

2486-
// When the default height-fit lays the page out wider than the viewport, every text line needs a horizontal pan, so the un-chosen default becomes width-fit.
2487-
// The criterion is the overflow itself, not a viewport-width proxy.
2494+
// The phone takes width-fit either way.
24882495
const heightFitOverflows = ((stageH - 150) / imgDims.height) * imgDims.width > stageW;
2489-
const effectiveMode = (isDefaultFit && heightFitOverflows) ? 'width' : fitMode;
2496+
const widthFitDefault = isDefaultFit && (this._phoneChrome || heightFitOverflows);
2497+
const effectiveMode = widthFitDefault ? 'width' : fitMode;
24902498

24912499
let zoom;
24922500
// `y` is the desired gap, in screen px, from the top of the viewport to the top of the first page.
@@ -2518,7 +2526,7 @@ class ScribePDFViewer {
25182526
sc.scrollLeft = Math.max(0, (this.scribe._contentWidth * zoom - stageW) / 2);
25192527

25202528
this._autoFit = {
2521-
imgDims, zoom, isDefaultFit, widthMode: isDefaultFit && heightFitOverflows,
2529+
imgDims, zoom, isDefaultFit, widthMode: widthFitDefault,
25222530
};
25232531
};
25242532
}
@@ -2696,39 +2704,6 @@ class ScribePDFViewer {
26962704
this._updateSplitButton();
26972705
}
26982706

2699-
/** Add a "Redact all" action to the find bar. */
2700-
_addRedactAllToSearchBar() {
2701-
const bar = this._searchBar;
2702-
if (!bar || !bar.findGroupElem) return;
2703-
const btn = this._makeTextBtn('Redact all', 'Mark every match for redaction');
2704-
btn.addEventListener('click', () => {
2705-
const matches = this.scribe._searchState?.matchList || [];
2706-
if (matches.length === 0 || !this.doc) {
2707-
this._showToast('No matches to redact.');
2708-
return;
2709-
}
2710-
/** @type {Map<number, Map<string, OcrWord>>} */
2711-
const wordsByPage = new Map();
2712-
let groups = 0;
2713-
for (const m of matches) {
2714-
let pageWords = wordsByPage.get(m.pageN);
2715-
if (!pageWords) {
2716-
pageWords = new Map();
2717-
const page = this.doc.ocr.active[m.pageN];
2718-
if (page) for (const line of page.lines) for (const w of line.words) pageWords.set(w.id, w);
2719-
wordsByPage.set(m.pageN, pageWords);
2720-
}
2721-
const words = m.wordIds.map((id) => pageWords.get(id)).filter((w) => !!w);
2722-
if (words.length > 0 && redactWords(this.scribe, words) > 0) groups++;
2723-
}
2724-
if (groups > 0) this._showToast(`${groups} match${groups === 1 ? '' : 'es'} marked for redaction.`);
2725-
// `redactWords` skips words existing marks cover, so a repeat click adds nothing.
2726-
else this._showToast('All matches are already marked for redaction.');
2727-
});
2728-
// Before the close button, keeping dismissal at the bar's end.
2729-
bar.findGroupElem.insertBefore(btn, bar.findGroupElem.lastElementChild);
2730-
}
2731-
27322707
/**
27332708
* Pages deep OCR would recognize for the current document, or 0 when there is none.
27342709
* Drives the Recognize Text button's visibility and the progress bar's page total.

scribe-ui/js/controls/bookmarksPanel.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function createBookmarksPanel(scribe, { onNavigate, onResize }) {
114114
* @param {number} [pageIndex]
115115
*/
116116
function addBookmarkAtPage(pageIndex = currentPage()) {
117-
if (!scribe.doc) return;
117+
if (!hasDoc()) return;
118118
const id = scribe.doc.addBookmark({ title: 'New bookmark', pageIndex });
119119
afterEdit();
120120
focusRename(id);
@@ -296,7 +296,7 @@ export function createBookmarksPanel(scribe, { onNavigate, onResize }) {
296296

297297
// Right-click the tree's empty space to add a top-level bookmark.
298298
treeElem.addEventListener('contextmenu', (e) => {
299-
if (!editing()) return;
299+
if (!editing() || !hasDoc()) return;
300300
if (e.target instanceof Element && e.target.closest('.scribe-bm-row')) return;
301301
e.preventDefault();
302302
openAddMenu(e.clientX, e.clientY);

0 commit comments

Comments
 (0)