diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59a9eada6..b2a645d9c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,4 +19,10 @@ jobs: cache: gradle - name: Build with Gradle - run: ./gradlew build --no-daemon + run: ./gradlew build --parallel --build-cache --no-daemon + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 936f70851..5088d645d 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -29,6 +29,7 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SearchView; import androidx.core.view.WindowCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; @@ -38,6 +39,9 @@ import com.google.android.material.snackbar.Snackbar; +import org.json.JSONException; +import org.json.JSONObject; + import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -68,46 +72,44 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private static final String KEY_PROPERTIES = "properties"; private static final int MIN_WEBVIEW_RELEASE = 133; - private static final String CONTENT_SECURITY_POLICY = - "default-src 'none'; " + - "form-action 'none'; " + - "connect-src 'self'; " + - "img-src blob: 'self'; " + - "script-src 'self'; " + - "style-src 'self'; " + - "frame-ancestors 'none'; " + - "base-uri 'none'"; - - private static final String PERMISSIONS_POLICY = - "accelerometer=(), " + - "ambient-light-sensor=(), " + - "autoplay=(), " + - "battery=(), " + - "camera=(), " + - "clipboard-read=(), " + - "clipboard-write=(), " + - "display-capture=(), " + - "document-domain=(), " + - "encrypted-media=(), " + - "fullscreen=(), " + - "gamepad=(), " + - "geolocation=(), " + - "gyroscope=(), " + - "hid=(), " + - "idle-detection=(), " + - "interest-cohort=(), " + - "magnetometer=(), " + - "microphone=(), " + - "midi=(), " + - "payment=(), " + - "picture-in-picture=(), " + - "publickey-credentials-get=(), " + - "screen-wake-lock=(), " + - "serial=(), " + - "speaker-selection=(), " + - "sync-xhr=(), " + - "usb=(), " + - "xr-spatial-tracking=()"; + private static final String CONTENT_SECURITY_POLICY = "default-src 'none'; " + + "form-action 'none'; " + + "connect-src 'self'; " + + "img-src blob: 'self'; " + + "script-src 'self'; " + + "style-src 'self'; " + + "frame-ancestors 'none'; " + + "base-uri 'none'"; + + private static final String PERMISSIONS_POLICY = "accelerometer=(), " + + "ambient-light-sensor=(), " + + "autoplay=(), " + + "battery=(), " + + "camera=(), " + + "clipboard-read=(), " + + "clipboard-write=(), " + + "display-capture=(), " + + "document-domain=(), " + + "encrypted-media=(), " + + "fullscreen=(), " + + "gamepad=(), " + + "geolocation=(), " + + "gyroscope=(), " + + "hid=(), " + + "idle-detection=(), " + + "interest-cohort=(), " + + "magnetometer=(), " + + "microphone=(), " + + "midi=(), " + + "payment=(), " + + "picture-in-picture=(), " + + "publickey-credentials-get=(), " + + "screen-wake-lock=(), " + + "serial=(), " + + "speaker-selection=(), " + + "sync-xhr=(), " + + "usb=(), " + + "xr-spatial-tracking=()"; private static final float MIN_ZOOM_RATIO = 0.2f; private static final float MAX_ZOOM_RATIO = 10f; @@ -130,18 +132,25 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private String mEncryptedDocumentPassword; private List mDocumentProperties; private InputStream mInputStream; + private String mCurrentQuery; private PdfviewerBinding binding; private TextView mTextView; - private Toast mToast; + private final Runnable hidePageNumberRunnable = () -> mTextView.setVisibility(View.GONE); private Snackbar snackbar; private PasswordPromptFragment mPasswordPromptFragment; public PdfViewModel viewModel; + private MenuItem mSearchMenuItem; + private MenuItem mNextMenuItem; + private MenuItem mPreviousMenuItem; + private final ActivityResultLauncher openDocumentLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { - if (result == null) return; - if (result.getResultCode() != RESULT_OK) return; + if (result == null) + return; + if (result.getResultCode() != RESULT_OK) + return; Intent resultData = result.getData(); if (resultData != null) { mUri = result.getData().getData(); @@ -156,8 +165,10 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private final ActivityResultLauncher saveAsLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { - if (result == null) return; - if (result.getResultCode() != RESULT_OK) return; + if (result == null) + return; + if (result.getResultCode() != RESULT_OK) + return; Intent resultData = result.getData(); if (resultData != null) { Uri path = resultData.getData(); @@ -208,6 +219,27 @@ public float getZoomFocusY() { return mZoomFocusY; } + @JavascriptInterface + public void updateSearchCounter(int current, int total) { + runOnUiThread(() -> { + if (total > 0) { + Toast.makeText(PdfViewer.this, getString(R.string.match_status, current, total), Toast.LENGTH_SHORT) + .show(); + } else { + Toast.makeText(PdfViewer.this, R.string.no_matches, Toast.LENGTH_SHORT).show(); + } + setSearchBusy(false); + }); + } + + @JavascriptInterface + public void showNoSearchResults() { + runOnUiThread(() -> { + setSearchBusy(false); + Toast.makeText(PdfViewer.this, R.string.no_matches, Toast.LENGTH_SHORT).show(); + }); + } + @JavascriptInterface public float getMinZoomRatio() { return MIN_ZOOM_RATIO; @@ -237,12 +269,13 @@ public void setDocumentProperties(final String properties) { final Bundle args = new Bundle(); args.putString(KEY_PROPERTIES, properties); - runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this).restartLoader(DocumentPropertiesAsyncTaskLoader.ID, args, PdfViewer.this)); + runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this) + .restartLoader(DocumentPropertiesAsyncTaskLoader.ID, args, PdfViewer.this)); } @JavascriptInterface public void showPasswordPrompt() { - if (!getPasswordPromptFragment().isAdded()){ + if (!getPasswordPromptFragment().isAdded()) { getPasswordPromptFragment().show(getSupportFragmentManager(), PasswordPromptFragment.class.getName()); } viewModel.passwordMissing(); @@ -265,6 +298,15 @@ public void onLoaded() { public String getPassword() { return mEncryptedDocumentPassword != null ? mEncryptedDocumentPassword : ""; } + + @JavascriptInterface + public void setPage(final int page) { + runOnUiThread(() -> { + mPage = page; + showPageNumber(); + invalidateOptionsMenu(); + }); + } } private void showWebViewCrashed() { @@ -276,7 +318,7 @@ private void showWebViewCrashed() { } @Override - @SuppressLint({"SetJavaScriptEnabled"}) + @SuppressLint({ "SetJavaScriptEnabled" }) protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); WindowCompat.setDecorFitsSystemWindows(getWindow(), false); @@ -284,7 +326,8 @@ protected void onCreate(Bundle savedInstanceState) { binding = PdfviewerBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); setSupportActionBar(binding.toolbar); - viewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())).get(PdfViewModel.class); + viewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())) + .get(PdfViewModel.class); viewModel.getOutline().observe(this, requested -> { if (requested instanceof PdfViewModel.OutlineStatus.Requested) { @@ -295,15 +338,14 @@ protected void onCreate(Bundle savedInstanceState) { getSupportFragmentManager().setFragmentResultListener(OutlineFragment.RESULT_KEY, this, (requestKey, result) -> { - final int newPage = result.getInt(OutlineFragment.PAGE_KEY, -1); - if (viewModel.shouldAbortOutline()) { - Log.d(TAG, "aborting outline operations"); - binding.webview.evaluateJavascript("abortDocumentOutline()", null); - viewModel.clearOutline(); - } else { - onJumpToPageInDocument(newPage); - } - }); + final int newPage = result.getInt(OutlineFragment.PAGE_KEY, -1); + if (viewModel.shouldAbortOutline()) { + binding.webview.evaluateJavascript("abortDocumentOutline()", null); + viewModel.clearOutline(); + } else { + onJumpToPageInDocument(newPage); + } + }); // Margins for the toolbar are needed, so that content of the toolbar // is not covered by a system button navigation bar when in landscape. @@ -346,7 +388,6 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque } final String path = url.getPath(); - Log.d(TAG, "path " + path); if ("/placeholder.pdf".equals(path)) { maybeCloseInputStream(); @@ -355,8 +396,8 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque if (mInputStream == null) { throw new FileNotFoundException(); } - } catch (final FileNotFoundException | IllegalArgumentException | - IllegalStateException | SecurityException ignored) { + } catch (final FileNotFoundException | IllegalArgumentException | IllegalStateException + | SecurityException ignored) { snackbar.setText(R.string.error_while_opening).show(); return null; } @@ -461,13 +502,14 @@ public void onZoomEnd() { } }); - mTextView = new TextView(this); + mTextView = binding.pageNumberView; mTextView.setBackgroundColor(Color.DKGRAY); mTextView.setTextColor(ColorStateList.valueOf(Color.WHITE)); mTextView.setTextSize(18); mTextView.setPadding(PADDING, 0, PADDING, 0); - // If loaders are not being initialized in onCreate(), the result will not be delivered + // If loaders are not being initialized in onCreate(), the result will not be + // delivered // after orientation change (See FragmentHostCallback), thus initialize the // loader manager impl so that the result will be delivered. LoaderManager.getInstance(this); @@ -541,12 +583,14 @@ void maybeCloseInputStream() { mInputStream = null; try { stream.close(); - } catch (IOException ignored) {} + } catch (IOException ignored) { + } } private PasswordPromptFragment getPasswordPromptFragment() { if (mPasswordPromptFragment == null) { - final Fragment fragment = getSupportFragmentManager().findFragmentByTag(PasswordPromptFragment.class.getName()); + final Fragment fragment = getSupportFragmentManager() + .findFragmentByTag(PasswordPromptFragment.class.getName()); if (fragment != null) { mPasswordPromptFragment = (PasswordPromptFragment) fragment; } else { @@ -578,7 +622,8 @@ protected void onResume() { } else { binding.webview.setVisibility(View.GONE); binding.webviewAlertTitle.setText(getString(R.string.webview_out_of_date_title)); - binding.webviewAlertMessage.setText(getString(R.string.webview_out_of_date_message, getWebViewRelease(), MIN_WEBVIEW_RELEASE)); + binding.webviewAlertMessage.setText( + getString(R.string.webview_out_of_date_message, getWebViewRelease(), MIN_WEBVIEW_RELEASE)); binding.webviewAlertLayout.setVisibility(View.VISIBLE); } } @@ -702,15 +747,10 @@ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { } private void showPageNumber() { - if (mToast != null) { - mToast.cancel(); - } + mTextView.removeCallbacks(hidePageNumberRunnable); mTextView.setText(String.format("%s/%s", mPage, mNumPages)); - mToast = new Toast(this); - mToast.setGravity(Gravity.BOTTOM | Gravity.END, PADDING, PADDING); - mToast.setDuration(Toast.LENGTH_SHORT); - mToast.setView(mTextView); - mToast.show(); + mTextView.setVisibility(View.VISIBLE); + mTextView.postDelayed(hidePageNumberRunnable, 2000); } @Override @@ -718,6 +758,50 @@ public boolean onCreateOptionsMenu(@NonNull Menu menu) { super.onCreateOptionsMenu(menu); MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.pdf_viewer, menu); + + mSearchMenuItem = menu.findItem(R.id.action_search); + mNextMenuItem = menu.findItem(R.id.action_next); + mPreviousMenuItem = menu.findItem(R.id.action_previous); + + if (mSearchMenuItem != null) { + final SearchView searchView = (SearchView) mSearchMenuItem.getActionView(); + + // Limit SearchView width to prevent pushing other items off-screen + int screenWidth = getResources().getDisplayMetrics().widthPixels; + searchView.setMaxWidth((int) (screenWidth * 0.45)); + + searchView.setQueryHint(getString(R.string.search_hint)); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + performSearch(query); + searchView.clearFocus(); + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + if (newText.isEmpty()) { + clearSearch(); + } + return false; + } + }); + + mSearchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + clearSearch(); + return true; + } + }); + } + if (BuildConfig.DEBUG) { inflater.inflate(R.menu.pdf_viewer_debug, menu); } @@ -727,10 +811,10 @@ public boolean onCreateOptionsMenu(@NonNull Menu menu) { @Override public boolean onPrepareOptionsMenu(@NonNull Menu menu) { final ArrayList ids = new ArrayList<>(Arrays.asList(R.id.action_jump_to_page, - R.id.action_next, R.id.action_previous, R.id.action_first, R.id.action_last, + R.id.action_first, R.id.action_last, R.id.action_rotate_clockwise, R.id.action_rotate_counterclockwise, R.id.action_view_document_properties, R.id.action_share, R.id.action_save_as, - R.id.action_outline)); + R.id.action_outline, R.id.action_previous, R.id.action_next)); if (BuildConfig.DEBUG) { ids.add(R.id.debug_action_toggle_text_layer_visibility); ids.add(R.id.debug_action_crash_webview); @@ -752,15 +836,13 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { mDocumentState = STATE_END; } - enableDisableMenuItem(menu.findItem(R.id.action_open), !webViewCrashed && getWebViewRelease() >= MIN_WEBVIEW_RELEASE); enableDisableMenuItem(menu.findItem(R.id.action_share), mUri != null); - enableDisableMenuItem(menu.findItem(R.id.action_next), mPage < mNumPages); - enableDisableMenuItem(menu.findItem(R.id.action_previous), mPage > 1); enableDisableMenuItem(menu.findItem(R.id.action_save_as), mUri != null); enableDisableMenuItem(menu.findItem(R.id.action_view_document_properties), mDocumentProperties != null); + enableDisableMenuItem(menu.findItem(R.id.action_search), mDocumentState == STATE_END); menu.findItem(R.id.action_outline).setVisible(viewModel.hasOutline()); @@ -777,10 +859,18 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { public boolean onOptionsItemSelected(MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.action_previous) { - onJumpToPageInDocument(mPage - 1); + if (mCurrentQuery != null && !mCurrentQuery.isEmpty()) { + findNext(true); + } else { + onJumpToPageInDocument(mPage - 1); + } return true; } else if (itemId == R.id.action_next) { - onJumpToPageInDocument(mPage + 1); + if (mCurrentQuery != null && !mCurrentQuery.isEmpty()) { + findNext(false); + } else { + onJumpToPageInDocument(mPage + 1); + } return true; } else if (itemId == R.id.action_first) { onJumpToPageInDocument(1); @@ -798,8 +888,7 @@ public boolean onOptionsItemSelected(MenuItem item) { documentOrientationChanged(-90); return true; } else if (itemId == R.id.action_outline) { - OutlineFragment outlineFragment = - OutlineFragment.newInstance(mPage, getCurrentDocumentName()); + OutlineFragment outlineFragment = OutlineFragment.newInstance(mPage, getCurrentDocumentName()); getSupportFragmentManager().beginTransaction() .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) // fullscreen fragment, since content root view == activity's root view @@ -809,12 +898,12 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (itemId == R.id.action_view_document_properties) { DocumentPropertiesFragment - .newInstance(mDocumentProperties) - .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); + .newInstance(mDocumentProperties) + .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); return true; } else if (itemId == R.id.action_jump_to_page) { new JumpToPageFragment() - .show(getSupportFragmentManager(), JumpToPageFragment.TAG); + .show(getSupportFragmentManager(), JumpToPageFragment.TAG); return true; } else if (itemId == R.id.action_share) { shareDocument(); @@ -841,7 +930,8 @@ private void saveDocument() { } private String getCurrentDocumentName() { - if (mDocumentProperties == null || mDocumentProperties.isEmpty()) return ""; + if (mDocumentProperties == null || mDocumentProperties.isEmpty()) + return ""; String fileName = ""; String title = ""; for (CharSequence property : mDocumentProperties) { @@ -870,9 +960,63 @@ private void saveDocumentAs(final Uri uri) { output.write(buffer, 0, read); } } - } catch (final IOException | IllegalArgumentException | IllegalStateException | - SecurityException e) { + } catch (final IOException | IllegalArgumentException | IllegalStateException | SecurityException e) { snackbar.setText(R.string.error_while_saving).show(); } } + + private void performSearch(String query) { + mCurrentQuery = query; + if (mCurrentQuery != null && !mCurrentQuery.isEmpty()) { + try { + JSONObject json = new JSONObject(); + json.put("type", "find"); + json.put("query", mCurrentQuery); + json.put("highlightAll", true); + sendJsonMessage(json); + } catch (JSONException e) { + Log.e(TAG, "Failed to construct JSON message", e); + } + setSearchBusy(true); + } + } + + private void clearSearch() { + mCurrentQuery = null; + try { + JSONObject json = new JSONObject(); + json.put("type", "closesearch"); + sendJsonMessage(json); + } catch (JSONException e) { + Log.e(TAG, "Failed to construct JSON message", e); + } + } + + private void findNext(boolean findPrevious) { + if (mCurrentQuery != null && !mCurrentQuery.isEmpty()) { + try { + JSONObject json = new JSONObject(); + json.put("type", "findagain"); + json.put("query", mCurrentQuery); + json.put("findPrevious", findPrevious); + sendJsonMessage(json); + } catch (JSONException e) { + Log.e(TAG, "Failed to construct JSON message", e); + } + } + } + + private void sendJsonMessage(JSONObject json) { + String script = "onMessage(" + json.toString() + ")"; + binding.webview.evaluateJavascript(script, null); + } + + private void setSearchBusy(boolean busy) { + if (mNextMenuItem != null) { + enableDisableMenuItem(mNextMenuItem, !busy); + } + if (mPreviousMenuItem != null) { + enableDisableMenuItem(mPreviousMenuItem, !busy); + } + } } diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 000000000..390774bbc --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/pdfviewer.xml b/app/src/main/res/layout/pdfviewer.xml index dc73b98af..f2b162338 100644 --- a/app/src/main/res/layout/pdfviewer.xml +++ b/app/src/main/res/layout/pdfviewer.xml @@ -104,4 +104,18 @@ + + diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/pdf_viewer.xml index 16cd9b289..6d21b82de 100644 --- a/app/src/main/res/menu/pdf_viewer.xml +++ b/app/src/main/res/menu/pdf_viewer.xml @@ -7,6 +7,13 @@ + + Outline Properties Close + Search + Find in document... + No matches found + %1$d / %2$d View nested outline entries No outline available diff --git a/viewer/css/text_layer.css b/viewer/css/text_layer.css index b636b99e1..c62188280 100644 --- a/viewer/css/text_layer.css +++ b/viewer/css/text_layer.css @@ -11,14 +11,13 @@ text-size-adjust: none; forced-color-adjust: none; caret-color: CanvasText; - z-index: 0; } .textLayer.highlighting { touch-action: none; } -.textLayer :is(span,br) { +.textLayer :is(span, br) { color: var(--text-layer-foreground); position: absolute; white-space: pre; @@ -26,7 +25,8 @@ transform-origin: 0% 0%; } -.textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent) { +.textLayer> :not(.markedContent), +.textLayer .markedContent span:not(.markedContent) { z-index: 1; } @@ -52,14 +52,12 @@ --highlight-bg-color: transparent; --highlight-selected-bg-color: transparent; --highlight-backdrop-filter: var(--hcm-highlight-filter); - --highlight-selected-backdrop-filter: var( - --hcm-highlight-selected-filter - ); + --highlight-selected-backdrop-filter: var(--hcm-highlight-selected-filter); } } .textLayer .highlight { - margin:-1px; + margin: -1px; padding: 1px; background-color: var(--highlight-bg-color); backdrop-filter: var(--highlight-backdrop-filter); @@ -109,6 +107,6 @@ top: 0; } -.textLayer.selecting ~ .annotationLayer section { +.textLayer.selecting~.annotationLayer section { pointer-events: none; -} +} \ No newline at end of file diff --git a/viewer/js/index.js b/viewer/js/index.js index 3eed8feec..a36801cbe 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -4,6 +4,7 @@ import { TextLayer, getDocument, } from "pdfjs-dist"; +import { SearchController } from "./search_controller.js"; GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; @@ -18,6 +19,54 @@ let orientationDegrees = 0; let zoomRatio = 1; let textLayerDiv = document.getElementById("text"); let task = null; +let searchController = new SearchController( + () => pdfDoc, + (pageNum) => { + // Only trigger render if navigating to a different page + if (channel.getPage() !== pageNum) { + // If the SearchController requests a page that we are currently + // prerendering (or rendering in general), ignore the request. + // This prevents the background prerender from hijacking the view. + if (pageRendering && newPageNumber === pageNum) { + console.log("Ignored phantom navigation to prerendered page: " + pageNum); + return; + } + renderPage(pageNum, false, false); + if (channel.setPage) { + channel.setPage(pageNum); + } + } + }, + () => { + if (channel && channel.showNoSearchResults) { + channel.showNoSearchResults(); + } + } +); +globalThis.onMessage = function (message) { + try { + let msg; + if (typeof message === "string") { + msg = JSON.parse(message); + } else { + msg = message; + } + + switch (msg.type) { + case "find": + searchController.find(msg.query); + break; + case "findagain": + searchController.findNext(msg.findPrevious); + break; + case "closesearch": + searchController.clear(); + break; + } + } catch (e) { + console.error("Failed to parse message: " + e); + } +}; let newPageNumber = 0; let newZoomRatio = 1; @@ -65,7 +114,7 @@ function display(newCanvas, zoom) { canvas.style.height = newCanvas.style.height; canvas.style.width = newCanvas.style.width; canvas.getContext("2d", { alpha: false }).drawImage(newCanvas, 0, 0); - if (!zoom) { + if (zoom === 0) { scrollTo(0, 0); } } @@ -80,7 +129,7 @@ function setLayerTransform(pageWidth, pageHeight, layerDiv) { function getDefaultZoomRatio(page, orientationDegrees) { const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: 1, rotation: totalRotation}); + const viewport = page.getViewport({ scale: 1, rotation: totalRotation }); const widthZoomRatio = document.body.clientWidth / viewport.width; const heightZoomRatio = document.body.clientHeight / viewport.height; return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio()); @@ -157,9 +206,9 @@ async function getSimplifiedOutline(pdfJsOutline, abortController) { const destRef = dest[0]; if (typeof destRef === "object") { pageNumberPromises.push( - pdfDoc.getPageIndex(destRef).then(function(index) { + pdfDoc.getPageIndex(destRef).then(function (index) { simpleChild.p = parseInt(index) + 1; - }).catch(function(error) { + }).catch(function (error) { console.log("pdfDoc.getPageIndex error: " + error); simpleChild.p = -1; }) @@ -177,6 +226,9 @@ async function getSimplifiedOutline(pdfJsOutline, abortController) { } function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { + if (searchController && !prerender) { + searchController.removeHighlights(); + } pageRendering = true; useRender = !prerender; @@ -184,11 +236,11 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { newZoomRatio = channel.getZoomRatio(); orientationDegrees = channel.getDocumentOrientationDegrees(); console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + - ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); + ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); for (let i = 0; i < cache.length; i++) { const cached = cache[i]; if (cached.pageNumber === pageNumber && cached.zoomRatio === newZoomRatio && - cached.orientationDegrees === orientationDegrees) { + cached.orientationDegrees === orientationDegrees) { if (useRender) { cache.splice(i, 1); cache.push(cached); @@ -200,6 +252,10 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); container.style.setProperty("--scale-factor", newZoomRatio.toString()); textLayerDiv.hidden = false; + + if (searchController) { + searchController.drawPageMatches(pageNumber, textLayerDiv, zoom === 1 || zoom === 2); + } } pageRendering = false; @@ -208,138 +264,181 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { } } - pdfDoc.getPage(pageNumber).then(function(page) { - if (maybeRenderNextPage()) { - return; - } - const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); - if (cache.length === 0) { - zoomRatio = defaultZoomRatio; - newZoomRatio = defaultZoomRatio; - channel.setZoomRatio(defaultZoomRatio); - } - const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); + let isCancelled = false; + let cancelPageRender = null; - const scaleFactor = newZoomRatio / zoomRatio; - const ratio = globalThis.devicePixelRatio; + task = { + promise: new Promise((resolve, reject) => { + cancelPageRender = () => { + isCancelled = true; + reject({ name: "RenderingCancelledException", message: "Render cancelled during getPage" }); + }; - if (useRender) { - if (newZoomRatio !== zoomRatio) { - canvas.style.height = viewport.height + "px"; - canvas.style.width = viewport.width + "px"; - } - zoomRatio = newZoomRatio; - } + pdfDoc.getPage(pageNumber).then(function (page) { + if (isCancelled) { + return; + } - if (zoom === 2) { - textLayerDiv.hidden = true; - pageRendering = false; + if (maybeRenderNextPage()) { + resolve(); + return; + } - // zoom focus relative to page origin, rather than screen origin - const globalFocusX = channel.getZoomFocusX() / ratio + globalThis.scrollX; - const globalFocusY = channel.getZoomFocusY() / ratio + globalThis.scrollY; + const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); - const translationFactor = scaleFactor - 1; - const scrollX = globalFocusX * translationFactor; - const scrollY = globalFocusY * translationFactor; - scrollBy(scrollX, scrollY); + if (cache.length === 0) { + zoomRatio = defaultZoomRatio; + newZoomRatio = defaultZoomRatio; + channel.setZoomRatio(defaultZoomRatio); + } - return; - } + const totalRotation = (orientationDegrees + page.rotate) % 360; + const viewport = page.getViewport({ scale: newZoomRatio, rotation: totalRotation }); - const resolutionY = viewport.height * ratio; - const resolutionX = viewport.width * ratio; - const renderPixels = resolutionY * resolutionX; - - let newViewport = viewport; - const maxRenderPixels = channel.getMaxRenderPixels(); - if (renderPixels > maxRenderPixels) { - console.log(`resolution ${renderPixels} exceeds maximum allowed ${maxRenderPixels}`); - const adjustedScale = Math.sqrt(maxRenderPixels / renderPixels); - newViewport = page.getViewport({ - scale: newZoomRatio * adjustedScale, - rotation: totalRotation - }); - } + const scaleFactor = newZoomRatio / zoomRatio; + const ratio = globalThis.devicePixelRatio; - const newCanvas = document.createElement("canvas"); - newCanvas.height = newViewport.height * ratio; - newCanvas.width = newViewport.width * ratio; - // use original viewport height for CSS zoom - newCanvas.style.height = viewport.height + "px"; - newCanvas.style.width = viewport.width + "px"; - const newContext = newCanvas.getContext("2d", { alpha: false }); - newContext.scale(ratio, ratio); - - task = page.render({ - canvasContext: newContext, - viewport: newViewport - }); + if (useRender) { + if (newZoomRatio !== zoomRatio) { + canvas.style.height = viewport.height + "px"; + canvas.style.width = viewport.width + "px"; + } + zoomRatio = newZoomRatio; + } - task.promise.then(function() { - task = null; + if (zoom === 2) { + container.style.setProperty("--scale-factor", newZoomRatio.toString()); + pageRendering = false; - let rendered = false; - function render() { - if (!useRender || rendered) { - return; - } - display(newCanvas, zoom); - rendered = true; - } - render(); - - const newTextLayerDiv = textLayerDiv.cloneNode(); - const textLayer = new TextLayer({ - textContentSource: page.streamTextContent(), - container: newTextLayerDiv, - viewport: viewport - }); - task = { - promise: textLayer.render(), - cancel: () => textLayer.cancel() - }; - task.promise.then(function() { - task = null; + // zoom focus relative to page origin, rather than screen origin + const globalFocusX = channel.getZoomFocusX() / ratio + globalThis.scrollX; + const globalFocusY = channel.getZoomFocusY() / ratio + globalThis.scrollY; - render(); + const translationFactor = scaleFactor - 1; + const scrollX = globalFocusX * translationFactor; + const scrollY = globalFocusY * translationFactor; + scrollBy(scrollX, scrollY); - setLayerTransform(viewport.width, viewport.height, newTextLayerDiv); - if (useRender) { - textLayerDiv.replaceWith(newTextLayerDiv); - textLayerDiv = newTextLayerDiv; - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; + resolve(); + return; } - if (cache.length === maxCached) { - cache.shift(); + const resolutionY = viewport.height * ratio; + const resolutionX = viewport.width * ratio; + const renderPixels = resolutionY * resolutionX; + + let newViewport = viewport; + const maxRenderPixels = channel.getMaxRenderPixels(); + if (renderPixels > maxRenderPixels) { + console.log(`resolution ${renderPixels} exceeds maximum allowed ${maxRenderPixels}`); + const adjustedScale = Math.sqrt(maxRenderPixels / renderPixels); + newViewport = page.getViewport({ + scale: newZoomRatio * adjustedScale, + rotation: totalRotation + }); } - cache.push({ - pageNumber: pageNumber, - zoomRatio: newZoomRatio, - orientationDegrees: orientationDegrees, - canvas: newCanvas, - textLayerDiv: newTextLayerDiv, - pageWidth: viewport.width, - pageHeight: viewport.height + + const newCanvas = document.createElement("canvas"); + newCanvas.height = newViewport.height * ratio; + newCanvas.width = newViewport.width * ratio; + // use original viewport height for CSS zoom + newCanvas.style.height = viewport.height + "px"; + newCanvas.style.width = viewport.width + "px"; + const newContext = newCanvas.getContext("2d", { alpha: false }); + newContext.scale(ratio, ratio); + + const renderTask = page.render({ + canvasContext: newContext, + viewport: newViewport }); - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - }).catch(handleRenderingError); - }).catch(handleRenderingError); - }); + cancelPageRender = () => { + isCancelled = true; + renderTask.cancel(); + }; + + renderTask.promise.then(function () { + if (isCancelled) { + return; + } + + let rendered = false; + function render() { + if (!useRender || rendered) { + return; + } + display(newCanvas, zoom); + rendered = true; + } + render(); + + const newTextLayerDiv = textLayerDiv.cloneNode(); + const textLayer = new TextLayer({ + textContentSource: page.streamTextContent(), + container: newTextLayerDiv, + viewport: viewport + }); + + const textLayerRenderTask = textLayer.render(); + cancelPageRender = () => { + isCancelled = true; + textLayer.cancel(); + }; + + textLayerRenderTask.then(function () { + if (isCancelled) { + return; + } + + render(); + + setLayerTransform(viewport.width, viewport.height, newTextLayerDiv); + if (useRender) { + textLayerDiv.replaceWith(newTextLayerDiv); + textLayerDiv = newTextLayerDiv; + container.style.setProperty("--scale-factor", newZoomRatio.toString()); + textLayerDiv.hidden = false; + + searchController.drawPageMatches(pageNumber, textLayerDiv, zoom === 1 || zoom === 2); + } + + if (cache.length === maxCached) { + cache.shift(); + } + cache.push({ + pageNumber: pageNumber, + zoomRatio: newZoomRatio, + orientationDegrees: orientationDegrees, + canvas: newCanvas, + textLayerDiv: newTextLayerDiv, + pageWidth: viewport.width, + pageHeight: viewport.height, + }); + + pageRendering = false; + doPrerender(pageNumber, prerenderTrigger); + resolve(); + }, reject); + }, reject); + }, reject); + }), + cancel: () => { + if (cancelPageRender) { + cancelPageRender(); + } + } + }; + + task.promise.catch(handleRenderingError); } globalThis.onRenderPage = function (zoom) { if (pageRendering) { if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && - orientationDegrees === channel.getDocumentOrientationDegrees()) { + orientationDegrees === channel.getDocumentOrientationDegrees()) { useRender = true; return; } @@ -360,17 +459,17 @@ globalThis.isTextSelected = function () { }; globalThis.getDocumentOutline = function () { - pdfDoc.getOutline().then(function(outline) { - getSimplifiedOutline(outline, outlineAbort).then(function(outlineEntries) { + pdfDoc.getOutline().then(function (outline) { + getSimplifiedOutline(outline, outlineAbort).then(function (outlineEntries) { if (outlineEntries !== null) { channel.setDocumentOutline(JSON.stringify(outlineEntries)); } else { channel.setDocumentOutline(null); } - }).catch(function(error) { + }).catch(function (error) { console.log("getSimplifiedOutline error: " + error); }); - }).catch(function(error) { + }).catch(function (error) { console.log("pdfDoc.getOutline error: " + error); }); }; @@ -425,9 +524,9 @@ globalThis.loadDocument = function () { }).catch(function (error) { console.log("getMetadata error: " + error); }); - pdfDoc.getOutline().then(function(outline) { + pdfDoc.getOutline().then(function (outline) { channel.setHasDocumentOutline(outline && outline.length > 0); - }).catch(function(error) { + }).catch(function (error) { console.log("getOutline error: " + error); }); renderPage(channel.getPage(), false, false); diff --git a/viewer/js/search_controller.js b/viewer/js/search_controller.js new file mode 100644 index 000000000..39fe2f7fa --- /dev/null +++ b/viewer/js/search_controller.js @@ -0,0 +1,289 @@ +export class SearchController { + constructor(getPdfDoc, getPageCallback, onNoResults) { + this.getPdfDoc = getPdfDoc; + this.getPageCallback = getPageCallback; // Function to render a specific page + + this.onNoResults = onNoResults; // Callback: () => {} + this.matches = []; // Array of { pageNum, matchIdx, matchLen } + this.currentMatchIndex = -1; + this.searchAbortController = null; + this.highlightOverlay = null; // Container for highlights + this.activeTextLayer = null; + this.activePage = 0; + this.pageTextCache = {}; + } + + async find(query) { + this.clear(); + if (!query) { + return; + } + + if (this.searchAbortController) { + this.searchAbortController.abort(); + } + this.searchAbortController = new AbortController(); + const signal = this.searchAbortController.signal; + + const pdfDoc = this.getPdfDoc(); + if (!pdfDoc) { + return; + } + + const numPages = pdfDoc.numPages; + const lowerQuery = query.toLowerCase(); + + // Search order detection: start at activePage, go to end, then wrap around to 1 + let searchOrder = []; + const startPage = this.activePage > 0 ? this.activePage : 1; + + for (let i = startPage; i <= numPages; i++) { + searchOrder.push(i); + } + for (let i = 1; i < startPage; i++) { + searchOrder.push(i); + } + + let firstMatchFound = false; + let lastYieldTime = Date.now(); + + for (const pageNum of searchOrder) { + if (signal.aborted) { + return; + } + + // Yield control periodically to keep UI responsive + const now = Date.now(); + if (now - lastYieldTime > 20) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + lastYieldTime = Date.now(); + } + + try { + const lowerText = await this._extractText(pdfDoc, pageNum); + + let matchesOnPage = []; + let startIndex = 0; + let matchIdx = lowerText.indexOf(lowerQuery, startIndex); + + while (matchIdx !== -1) { + matchesOnPage.push({ + pageNum: pageNum, + matchIdx: matchIdx, + matchLen: query.length + }); + startIndex = matchIdx + query.length; + matchIdx = lowerText.indexOf(lowerQuery, startIndex); + } + + if (matchesOnPage.length > 0) { + // Store the current match object before modification/sorting + const currentMatchBeforeSort = (this.currentMatchIndex !== -1 && this.currentMatchIndex < this.matches.length) + ? this.matches[this.currentMatchIndex] + : null; + + this.matches.push(...matchesOnPage); + // Sort matches by page order to keep navigation logical + this.matches.sort((a, b) => { + if (a.pageNum !== b.pageNum) return a.pageNum - b.pageNum; + return a.matchIdx - b.matchIdx; + }); + + // Restore the correct index for the previously selected match + if (currentMatchBeforeSort) { + this.currentMatchIndex = this.matches.indexOf(currentMatchBeforeSort); + } + + // If this is the *very first* match we found in this session, highlight it safely + if (!firstMatchFound) { + firstMatchFound = true; + + // Find the index of the first match on the startPage (or closest to it) + // Because we sorted, we just need to find where our current page matches started + const firstMatchIndex = this.matches.findIndex(m => m.pageNum === pageNum); + if (firstMatchIndex !== -1) { + this.currentMatchIndex = firstMatchIndex; + this.showMatch(this.matches[this.currentMatchIndex]); + } + } + + + } + + } catch (e) { + console.error(`Error searching page ${pageNum}:`, e); + } + } + + if (this.matches.length === 0) { + if (this.onNoResults) { + this.onNoResults(); + } + } + } + + async _extractText(pdfDoc, pageNum) { + if (this.pageTextCache[pageNum]) { + return this.pageTextCache[pageNum]; + } + + const page = await pdfDoc.getPage(pageNum); + const textContent = await page.getTextContent(); + const strings = textContent.items.map((item) => item.str); + const originalText = strings.join(""); + const lowerText = originalText.toLowerCase(); + + this.pageTextCache[pageNum] = lowerText; + return lowerText; + } + + findNext(findPrevious) { + if (this.matches.length === 0) { + return; + } + + if (findPrevious) { + this.currentMatchIndex--; + if (this.currentMatchIndex < 0) { + this.currentMatchIndex = this.matches.length - 1; + } + } else { + this.currentMatchIndex++; + if (this.currentMatchIndex >= this.matches.length) { + this.currentMatchIndex = 0; + } + } + + this.showMatch(this.matches[this.currentMatchIndex]); + } + + + clear() { + if (this.searchAbortController) { + this.searchAbortController.abort(); + this.searchAbortController = null; + } + this.matches = []; + this.currentMatchIndex = -1; + this.removeHighlights(); + } + + showMatch(match) { + let textLayer = this.activeTextLayer; + if (textLayer && !textLayer.isConnected) { + textLayer = null; + } + textLayer = textLayer || document.getElementById("text"); + + let currentPage = this.activePage; + if (typeof channel !== "undefined" && channel.getPage()) { + currentPage = channel.getPage(); + } + + if (match.pageNum === currentPage && textLayer) { + this.drawPageMatches(match.pageNum, textLayer); + } + + this.getPageCallback(match.pageNum); + } + + drawPageMatches(pageNum, textLayerDiv, skipScroll = false) { + this.removeHighlights(); + this.activeTextLayer = textLayerDiv; + this.activePage = pageNum; + + if (this.matches.length === 0) { + return; + } + + const pageMatches = this.matches.filter((m) => m.pageNum === pageNum); + if (pageMatches.length === 0) { + return; + } + + if (!textLayerDiv) { + return; + } + + const container = document.createElement("div"); + container.className = "search-highlight-container"; + + const spans = textLayerDiv.querySelectorAll("span"); + const currentMatch = (this.currentMatchIndex !== -1) ? this.matches[this.currentMatchIndex] : null; + let selectedDiv = null; + + // Get the scale factor from CSS custom property + const containerEl = document.getElementById("container"); + const scaleFactor = parseFloat(containerEl.style.getPropertyValue("--scale-factor")) || 1; + + let currentLen = 0; + + spans.forEach((span) => { + const text = span.textContent; + const spanLen = text.length; + const spanStart = currentLen; + const spanEnd = currentLen + spanLen; + + for (const match of pageMatches) { + const matchStart = match.matchIdx; + const matchEnd = match.matchIdx + match.matchLen; + + if (spanEnd > matchStart && spanStart < matchEnd) { + const startOffset = Math.max(0, matchStart - spanStart); + const endOffset = Math.min(spanLen, matchEnd - spanStart); + + if (span.firstChild) { + const range = document.createRange(); + range.setStart(span.firstChild, startOffset); + range.setEnd(span.firstChild, endOffset); + + const clientRects = range.getClientRects(); + const textLayerRect = textLayerDiv.getBoundingClientRect(); + + for (const rect of clientRects) { + const div = document.createElement("div"); + div.className = "search-highlight"; + + if (match === currentMatch) { + div.classList.add("selected"); + if (!selectedDiv) { + selectedDiv = div; + } + } + + // Calculate positions relative to text layer using current viewport positions + // and then normalize by the scale factor to get unscaled coordinates + const left = (rect.left - textLayerRect.left) / scaleFactor; + const top = (rect.top - textLayerRect.top) / scaleFactor; + const width = rect.width / scaleFactor; + const height = rect.height / scaleFactor; + + div.style.left = left + "px"; + div.style.top = top + "px"; + div.style.width = width + "px"; + div.style.height = height + "px"; + + container.appendChild(div); + } + } + } + } + + currentLen += spanLen; + }); + + textLayerDiv.appendChild(container); + this.highlightOverlay = container; + + if (selectedDiv && !skipScroll) { + selectedDiv.scrollIntoView({ block: "center", inline: "center" }); + } + } + + removeHighlights() { + if (this.highlightOverlay) { + this.highlightOverlay.remove(); + this.highlightOverlay = null; + } + } +} diff --git a/viewer/main.css b/viewer/main.css index fb59309fd..016ce1c34 100644 --- a/viewer/main.css +++ b/viewer/main.css @@ -1,2 +1,24 @@ @import url(css/pdf_viewer.css); @import url(css/text_layer.css); + +.search-highlight-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 2; + transform-origin: 0 0; + transform: scale(var(--scale-factor, 1)); +} + +.search-highlight { + position: absolute; + background-color: rgba(204, 226, 254, 0.6); + border-radius: 2px; +} + +.search-highlight.selected { + background-color: rgba(122, 171, 217, 0.7); +} \ No newline at end of file