Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9230e4b
Use SDT for Reading Mode and Read Aloud
AbeJellinek Mar 27, 2026
62f0942
Show spinner by Reading Mode switch while loading ZST
AbeJellinek Apr 24, 2026
93f27ba
Only pull some view stats from hidden base view
AbeJellinek Apr 24, 2026
1fd7145
Update SDT submodule
AbeJellinek Apr 24, 2026
52e550d
Read Aloud: Support highlightGranularity option
AbeJellinek Apr 29, 2026
d9f2fef
Read Aloud: Support word-level highlighting
AbeJellinek Apr 29, 2026
c7c1560
Remove duplicate walker code
AbeJellinek Apr 30, 2026
fb9f9a7
Update SDT submodule
AbeJellinek Apr 30, 2026
8d9a1e8
Don't fall back to block anchor rects when mapping positions
AbeJellinek Apr 30, 2026
5ddfa5b
Support streaming SDT segments
AbeJellinek May 6, 2026
6de29bc
Extract worker client code from index.dev.js
AbeJellinek May 6, 2026
9e37a89
Fix start position and persistence regressions
AbeJellinek May 6, 2026
2f31bb7
Fix jump button regressions
AbeJellinek May 7, 2026
0839438
EPUB/Snapshot: Only navigate when segment actually changed
AbeJellinek May 7, 2026
b0c9d2a
Read Aloud: EPUB: stop emitting bogus CFIs for non-text-node entries
AbeJellinek May 7, 2026
e732d59
PDF: Read Aloud: Fix jump button on numbered headings
AbeJellinek May 21, 2026
967d0a6
SDT: Fix annotation positioning during/after resize
AbeJellinek May 21, 2026
aca74ce
SDT: Never scroll horizontally
AbeJellinek May 21, 2026
55c09dd
Support binary SDT packs
AbeJellinek May 21, 2026
c14ea2b
Update sdt submodule
AbeJellinek May 21, 2026
2b416c3
Drop streaming SDT, preload at launch
AbeJellinek May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
[submodule "epubjs/epub.js"]
path = epubjs/epub.js
url = https://github.com/zotero/epub.js.git
[submodule "structured-document-text"]
path = structured-document-text
url = https://github.com/zotero/structured-document-text.git
8 changes: 4 additions & 4 deletions src/common/annotation-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class AnnotationManager {
colors: [],
tags: [],
authors: [],
hiddenIDs: [],
enabledTypes: null,
};
this._readOnly = options.readOnly;
this._authorName = options.authorName;
Expand Down Expand Up @@ -399,10 +399,10 @@ class AnnotationManager {
this._annotations.forEach(x => delete x._score);

let annotations = this._annotations.slice();
let { tags, colors, authors, query, hiddenIDs } = this._filter;
let { tags, colors, authors, query, enabledTypes } = this._filter;

if (hiddenIDs.length) {
annotations = annotations.filter(x => !hiddenIDs.includes(x.id));
if (enabledTypes) {
annotations = annotations.filter(x => enabledTypes.includes(x.type));
}

if (tags.length || colors.length || authors.length) {
Expand Down
136 changes: 71 additions & 65 deletions src/common/components/modal-popup/appearance-popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import IconSplitVertical from '../../../../res/icons/16/split-vertical.svg';
import IconSpreadEven from '../../../../res/icons/16/spread-even.svg';
import IconSpreadNone from '../../../../res/icons/16/spread-none.svg';
import IconSpreadOdd from '../../../../res/icons/16/spread-odd.svg';
import IconX from '../../../../res/icons/16/x-8.svg';
import IconOptions from '../../../../res/icons/16/options.svg';
import IconPlus from '../../../../res/icons/20/plus.svg';
import IconLoading from '../../../../res/icons/16/loading.svg';
import { getCurrentColorScheme, getPopupCoordinatesFromClickEvent } from '../../lib/utilities';
import { ReaderContext } from '../../reader';
import { DEFAULT_THEMES } from '../../defines';
Expand Down Expand Up @@ -260,56 +260,6 @@ function AppearancePopup(props) {
return (
<div ref={overlayRef} className="toolbar-popup-overlay overlay" onPointerDown={handlePointerDown}>
<div className={cx('modal-popup appearance-popup')}>
{type === 'pdf' && (
<div className="group">
<div className="option">
<label>{l10n.getString('reader-scroll-mode')}</label>
<div className="split-toggle" data-tabstop={1}>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 0 })}
title={l10n.getString('reader-vertical')}
onClick={() => props.onChangeScrollMode(0)}
><IconScrollVertical/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 1 })}
title={l10n.getString('reader-horizontal')}
onClick={() => props.onChangeScrollMode(1)}
><IconScrollHorizontal/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 2 })}
title={l10n.getString('reader-wrapped')}
onClick={() => props.onChangeScrollMode(2)}
><IconScrollWrapped/></button>
</div>
</div>
<div className="option">
<label>{l10n.getString('reader-spread-mode')}</label>
<div className="split-toggle" data-tabstop={1}>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 0 })}
title={l10n.getString('reader-none')}
onClick={() => props.onChangeSpreadMode(0)}
><IconSpreadNone/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 1 })}
title={l10n.getString('reader-odd')}
onClick={() => props.onChangeSpreadMode(1)}
><IconSpreadOdd/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 2 })}
title={l10n.getString('reader-even')}
onClick={() => props.onChangeSpreadMode(2)}
><IconSpreadEven/></button>
</div>
</div>
</div>
)}
{type === 'epub' && (
<div className="group">
<div className="option">
Expand Down Expand Up @@ -352,33 +302,89 @@ function AppearancePopup(props) {
)}
</div>
)}
{(type === 'epub' || type === 'snapshot') && !(type === 'epub' && props.viewStats.fixedLayout) && (
{!(type === 'epub' && props.viewStats.fixedLayout) && (
<div className="group">
{type === 'snapshot' && (
{(type === 'snapshot' || type === 'pdf') && (
<div className="option">
<label htmlFor="reading-mode-enabled">{l10n.getString('reader-reading-mode')}</label>
<input
data-tabstop={1}
tabIndex={-1}
className="switch"
type="checkbox"
id="reading-mode-enabled"
checked={props.viewStats.readingModeEnabled}
onChange={e => props.onChangeReadingModeEnabled(e.target.checked)}
/>
<div className="reading-mode-control">
{props.readingModeLoading && (
<IconLoading className="loading-spinner" aria-busy={true}/>
)}
<input
data-tabstop={1}
tabIndex={-1}
className="switch"
type="checkbox"
id="reading-mode-enabled"
checked={props.readingModeEnabled || props.readingModeLoading}
disabled={props.readingModeLoading}
onChange={e => props.onChangeReadingModeEnabled(e.target.checked)}
/>
</div>
</div>
)}
{(type === 'epub' || props.viewStats.readingModeEnabled) && (
{(type === 'epub' || props.readingModeEnabled) && (
<ReflowableAppearanceSection
params={props.viewStats.appearance}
enablePageWidth={type === 'snapshot'
enablePageWidth={type !== 'epub'
|| props.viewStats.flowMode !== 'paginated' || props.viewStats.spreadMode === 0}
onChange={props.onChangeAppearance}
indent={type === 'snapshot'}
indent={type === 'snapshot' || type === 'pdf'}
/>
)}
</div>
)}
{type === 'pdf' && !props.viewStats.readingModeEnabled && (
<div className="group">
<div className="option">
<label>{l10n.getString('reader-scroll-mode')}</label>
<div className="split-toggle" data-tabstop={1}>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 0 })}
title={l10n.getString('reader-vertical')}
onClick={() => props.onChangeScrollMode(0)}
><IconScrollVertical/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 1 })}
title={l10n.getString('reader-horizontal')}
onClick={() => props.onChangeScrollMode(1)}
><IconScrollHorizontal/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.scrollMode === 2 })}
title={l10n.getString('reader-wrapped')}
onClick={() => props.onChangeScrollMode(2)}
><IconScrollWrapped/></button>
</div>
</div>
<div className="option">
<label>{l10n.getString('reader-spread-mode')}</label>
<div className="split-toggle" data-tabstop={1}>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 0 })}
title={l10n.getString('reader-none')}
onClick={() => props.onChangeSpreadMode(0)}
><IconSpreadNone/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 1 })}
title={l10n.getString('reader-odd')}
onClick={() => props.onChangeSpreadMode(1)}
><IconSpreadOdd/></button>
<button
tabIndex={-1}
className={cx({ active: props.viewStats.spreadMode === 2 })}
title={l10n.getString('reader-even')}
onClick={() => props.onChangeSpreadMode(2)}
><IconSpreadEven/></button>
</div>
</div>
</div>
)}
<div className="group">
<div className="option">
<label>{l10n.getString('reader-split-view')}</label>
Expand Down
4 changes: 3 additions & 1 deletion src/common/components/reader-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const ReaderUI = React.forwardRef((props, ref) => {
enableNavigateBack={viewStats.canNavigateBack}
enableNavigateToPreviousPage={viewStats.canNavigateToPreviousPage}
enableNavigateToNextPage={viewStats.canNavigateToNextPage}
readingModeEnabled={viewStats.readingModeEnabled}
readingModeEnabled={state.readingModeEnabled}
appearancePopup={state.appearancePopup}
readAloudState={state.readAloudState}
findPopupOpen={findState.popupOpen}
Expand Down Expand Up @@ -235,6 +235,8 @@ const ReaderUI = React.forwardRef((props, ref) => {
colorScheme={state.colorScheme}
lightTheme={state.lightTheme}
darkTheme={state.darkTheme}
readingModeEnabled={state.readingModeEnabled}
readingModeLoading={state.readingModeLoading}
splitType={state.splitType}
viewStats={viewStats}
onChangeSplitType={props.onChangeSplitType}
Expand Down
6 changes: 3 additions & 3 deletions src/common/components/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ function Toolbar(props) {
onClick={() => handleToolClick('note')}
><IconNote/></button>
</Localized>
{props.type === 'pdf' && (
{props.type === 'pdf' && !props.readingModeEnabled && (
<Localized id="reader-toolbar-text" attrs={{ title: true, 'aria-description': true }}>
<button
tabIndex={-1}
Expand All @@ -221,7 +221,7 @@ function Toolbar(props) {
><IconText/></button>
</Localized>
)}
{props.type === 'pdf' && (
{props.type === 'pdf' && !props.readingModeEnabled && (
<Localized id="reader-toolbar-area" attrs={{ title: true, 'aria-description': true }}>
<button
tabIndex={-1}
Expand All @@ -231,7 +231,7 @@ function Toolbar(props) {
><IconImage/></button>
</Localized>
)}
{props.type === 'pdf' && (
{props.type === 'pdf' && !props.readingModeEnabled && (
<Localized id="reader-toolbar-draw" attrs={{ title: true, 'aria-description': true }}>
<button
tabIndex={-1}
Expand Down
19 changes: 15 additions & 4 deletions src/common/read-aloud/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ export abstract class ReadAloudController extends EventTarget {

protected abstract get _segmentProgressSeconds(): number;

/**
* Index into the active segment's timestamp array for the word currently
* being spoken, or null if no word-level data is available. Updated by the
* controller as audio plays.
*/
activeTimestampIndex: number | null = null;

protected get _currentSegment() {
return this._segments[this._position];
}
Expand Down Expand Up @@ -137,6 +144,13 @@ export abstract class ReadAloudController extends EventTarget {
this._segments = segments;
}

private _scheduleSpeak(delay: number) {
this._delayTimeout = setTimeout(() => {
this._delayTimeout = null;
this._speak();
}, delay);
}

override dispatchEvent(event: Event): boolean {
if (this._destroyed) {
return false;
Expand Down Expand Up @@ -254,10 +268,7 @@ export abstract class ReadAloudController extends EventTarget {
if (this._currentSegment?.anchor === 'paragraphStart') {
delay += DELAY_PARAGRAPH;
}
this._delayTimeout = setTimeout(() => {
this._delayTimeout = null;
this._speak();
}, delay);
this._scheduleSpeak(delay);
}
}
}
Expand Down
35 changes: 9 additions & 26 deletions src/common/read-aloud/jump-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,9 @@ export class ReadAloudJumpButton {
this._el.style.display = 'none';
}

/**
* A DOMRect spanning the primary click target of the jump button.
* While the actual click target spans the entire margin from top to bottom,
* this rect is the only area that should prevent the jump button from moving
* while hovered, even if the pointer technically enters another paragraph.
*
* @return {DOMRect | null}
*/
get iconTargetRect() {
iconContainsPoint(x, y) {
if (this._el.style.display === 'none') {
return null;
return false;
}
let hostRect = this._el.getBoundingClientRect();
let rtl = getComputedStyle(this._el).direction === 'rtl';
Expand All @@ -113,21 +105,12 @@ export class ReadAloudJumpButton {
let iconInlineEnd = 8;
let verticalMargin = 4;

let x, width;
if (rtl) {
// In RTL, paragraph edge is host's left edge, icon is near the left
x = hostRect.left;
width = iconInlineEnd + iconSize;
}
else {
// In LTR, paragraph edge is host's right edge, icon is near the right
x = hostRect.right - iconInlineEnd - iconSize;
width = iconInlineEnd + iconSize;
}

let y = hostRect.top - verticalMargin;
let height = iconSize + verticalMargin * 2;

return new DOMRect(x, y, width, height);
let iconLeft = rtl
? hostRect.left
: hostRect.right - iconInlineEnd - iconSize;
let iconRight = iconLeft + iconInlineEnd + iconSize;
let iconTop = hostRect.top - verticalMargin;
let iconBottom = iconTop + iconSize + verticalMargin * 2;
return x >= iconLeft && x <= iconRight && y >= iconTop && y <= iconBottom;
}
}
Loading
Loading