Skip to content

Commit 5729cd0

Browse files
committed
feat: improve reading layout and translation behavior
Add optional paragraph gap compaction and reading-ahead translation scheduling. Preserve cropped canvas clarity and annotation visibility, improve external application handoff, and refine settings navigation and controls.
1 parent d68b761 commit 5729cd0

39 files changed

Lines changed: 1064 additions & 94 deletions

‎electron/main/ipc/preferences-ipc.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const SETTINGS_PREFERENCES = [
2323
'reduceResourceUsage',
2424
'restoreDocuments',
2525
'interactionMode',
26+
'optimizeParagraphGaps',
2627
'language',
2728
'sourceLanguage',
2829
'concurrency',

‎electron/main/menus/application-menu.mjs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,18 @@ export function createApplicationMenu({
6868
const receiver = target || focusedWindow();
6969
const currentPath = documentPath(receiver);
7070
if (!currentPath) return;
71+
const source = registry.stateFor(receiver)?.unkeyedAnnotationSource;
72+
const stillCurrent = () =>
73+
!receiver.isDestroyed() &&
74+
documentPath(receiver) === currentPath &&
75+
registry.stateFor(receiver)?.unkeyedAnnotationSource === source;
7176
try {
7277
await validateSystemPDF(currentPath);
73-
if (documentPath(receiver) !== currentPath || receiver.isDestroyed()) return;
78+
if (!stillCurrent()) return;
7479
await receiver.webContents.executeJavaScript('window.previewSaveReadingView?.()');
75-
if (documentPath(receiver) !== currentPath || receiver.isDestroyed()) return;
80+
if (!stillCurrent()) return;
7681
await pdfApplications.open(choice, currentPath);
82+
if (stillCurrent()) receiver.webContents.send('reader:action', 'close-document');
7783
} catch (error) {
7884
dialog.showErrorBox('PDFMathReader', error.message);
7985
}

‎electron/main/services/preferences.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ const DEFAULT_PREFERENCES = Object.freeze({
2424
columns: 1,
2525
fit: 'width',
2626
zoom: 1,
27-
translationMode: 'reading',
27+
translationMode: 'reading-ahead',
2828
autoCheckUpdates: true,
2929
cacheLimitMB: null,
3030
documentOpenMode: 'translation',
3131
interactionMode: 'reading',
32+
optimizeParagraphGaps: false,
3233
restoreDocuments: true,
3334
formulaOcrEnabled: false,
3435
translationErrorDismissals: [],
@@ -232,6 +233,7 @@ const VALIDATORS = {
232233
cacheLimitMB: (value) => value === null || [512, 1024, 2048, 5120, 10240].includes(value),
233234
documentOpenMode: (value) => ['translation', 'original', 'manual'].includes(value),
234235
restoreDocuments: (value) => typeof value === 'boolean',
236+
optimizeParagraphGaps: (value) => typeof value === 'boolean',
235237
reduceResourceUsage: (value) => typeof value === 'boolean',
236238
reduceBackgroundFrameRate: (value) => typeof value === 'boolean',
237239
reuseTranslations: (value) => typeof value === 'boolean',
@@ -249,7 +251,7 @@ const VALIDATORS = {
249251
columns: (value) => [1, 2, 4].includes(value),
250252
fit: (value) => ['width', 'height', 'manual'].includes(value),
251253
zoom: isValidZoom,
252-
translationMode: (value) => ['full', 'reading'].includes(value),
254+
translationMode: (value) => ['full', 'reading', 'reading-ahead'].includes(value),
253255
interactionMode: (value) => ['reading', 'comparison'].includes(value),
254256
interfaceStyle: (value) => ['default', 'liquid-glass'].includes(value),
255257
appearance: (value) => ['light', 'dark', 'system'].includes(value),
@@ -278,6 +280,7 @@ const VALIDATORS = {
278280
const VALIDATION_MESSAGES = {
279281
documentOpenMode: 'Invalid document opening mode',
280282
restoreDocuments: 'Invalid document restoration preference',
283+
optimizeParagraphGaps: 'Invalid paragraph gap optimization preference',
281284
reuseTranslations: 'Invalid translation reuse preference',
282285
translationMode: 'Invalid translation mode',
283286
interactionMode: 'Invalid interaction mode',

‎src/App.vue‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const {
119119
openRecent,
120120
openSearch,
121121
openSettings,
122+
optimizeParagraphGaps,
122123
pageConcurrency,
123124
pageCrop,
124125
pageEntry,
@@ -528,6 +529,7 @@ const copyToastDetail = computed(() =>
528529
v-model:error="error"
529530
v-model:document-open-mode="documentOpenMode"
530531
v-model:interaction-mode="interactionMode"
532+
v-model:optimize-paragraph-gaps="optimizeParagraphGaps"
531533
v-model:restore-documents="restoreDocuments"
532534
v-model:auto-hide-header="autoHideHeader"
533535
v-model:emphasize-topic-sentences="emphasizeTopicSentences"

‎src/app/useReaderWindow.mjs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
createParagraphCompaction,
3+
mapCompactY,
4+
} from '../features/reader/paragraph-compaction.mjs';
15
import { installRenderDiagnostics } from '../features/reader/render-diagnostics.mjs';
26
import { installReaderViewObservers } from '../features/reader/view-observers.mjs';
37
import { installReaderPreferenceObservers } from '../features/settings/preference-observers.mjs';
@@ -59,6 +63,7 @@ export function useReaderWindow() {
5963
layoutVisible,
6064
reuseTranslations,
6165
interactionMode,
66+
optimizeParagraphGaps,
6267
restoreDocuments,
6368
documentOpenMode,
6469
defaultPageCropEnabled,
@@ -327,6 +332,7 @@ export function useReaderWindow() {
327332
viewportPages: featureActions.viewportPages,
328333
scheduleViewport: featureActions.scheduleViewport,
329334
scheduleReadingSave: featureActions.scheduleReadingSave,
335+
mapDisplayPage: (p) => displayedPage(p),
330336
importFile: featureActions.importFile,
331337
ensurePDF: featureActions.ensurePDF,
332338
active,
@@ -347,7 +353,7 @@ export function useReaderWindow() {
347353
searchPages,
348354
searchPageCount,
349355
searchHit,
350-
displayedPage,
356+
displayedPage: searchedPage,
351357
openSearch,
352358
closeSearch,
353359
scheduleSearch,
@@ -431,6 +437,44 @@ export function useReaderWindow() {
431437

432438
// Display-only, total fraction removed symmetrically from each axis.
433439
const pageCrop = ref({ x: 0, y: 0 });
440+
const compactParagraphPage = createParagraphCompaction({
441+
enabled: computed(
442+
() =>
443+
preferences.optimizeParagraphGaps.value &&
444+
direction.value === 'vertical' &&
445+
columns.value === 1 &&
446+
interactionMode.value === 'reading' &&
447+
showTranslations.value,
448+
),
449+
pages,
450+
sourceDocument: () => session.pdf,
451+
getDocument: (options) => session.getDocument(options),
452+
changing: (page, result) => {
453+
const el = reader.value,
454+
number = active.value;
455+
const host = renderState.pageEls.get(number);
456+
if (!el || !host) return () => {};
457+
const top = el.getBoundingClientRect().top;
458+
const offset = top - host.getBoundingClientRect().top;
459+
const mapped =
460+
page.number === number
461+
? mapCompactY(offset / zoom.value, result.cuts) * zoom.value
462+
: offset;
463+
return () => {
464+
const current = renderState.pageEls.get(number);
465+
if (current && reader.value === el)
466+
el.scrollTop += current.getBoundingClientRect().top + mapped - top;
467+
};
468+
},
469+
changed: (restore) =>
470+
nextTick(() => {
471+
restore?.();
472+
featureActions.renderPages(false, true);
473+
}),
474+
});
475+
function displayedPage(p) {
476+
return compactParagraphPage(searchedPage(p));
477+
}
434478
// Read geometry only: spreading a reactive page subscribes the full-document
435479
// layout to visibility, translation and annotation updates on every scroll.
436480

@@ -482,6 +526,11 @@ export function useReaderWindow() {
482526
labelKey: 'translation.reading',
483527
descriptionKey: 'translation.readingDescription',
484528
},
529+
{
530+
id: 'reading-ahead',
531+
labelKey: 'translation.readingAhead',
532+
descriptionKey: 'translation.readingAheadDescription',
533+
},
485534
];
486535

487536
watch(translationMode, () => {
@@ -808,6 +857,7 @@ export function useReaderWindow() {
808857
emphasizeTopicSentences,
809858
showKernelToolbarShortcut,
810859
autoHideHeader,
860+
optimizeParagraphGaps,
811861
kernelAdvancedOptions,
812862
translationServices,
813863
glossaries,
@@ -1013,6 +1063,7 @@ export function useReaderWindow() {
10131063
informationCategories,
10141064
informationCategorySettings,
10151065
interactionMode,
1066+
optimizeParagraphGaps,
10161067
kernelAdvancedOptions,
10171068
kernelErrorVisible,
10181069
kernelFailure,

‎src/app/window-lifecycle.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ export function installWindowLifecycle({ bindings, lifecycle }) {
116116
bindings.direction.value = saved.direction || 'vertical';
117117
bindings.columns.value = saved.columns || 1;
118118
bindings.fitMode.value = saved.fit;
119-
bindings.translationMode.value = saved.translationMode || 'reading';
119+
bindings.translationMode.value = ['full', 'reading', 'reading-ahead'].includes(
120+
saved.translationMode,
121+
)
122+
? saved.translationMode
123+
: 'reading-ahead';
120124
if (saved.fit === 'manual') {
121125
bindings.zoom.value = saved.zoom;
122126
bindings.zoomEntry.value = formatPercentValue(bindings.zoom.value);

‎src/features/annotations/ReadingAnnotations.vue‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function updatePopupLayout() {
3333
const props = defineProps({
3434
host: Function,
3535
page: Object,
36+
crop: { type: Object, default: () => ({ x: 0, y: 0 }) },
3637
zoom: Number,
3738
annotations: Array,
3839
translated: Boolean,
@@ -154,7 +155,7 @@ const noteRail = computed(() => {
154155
: b.sourceBox) || b;
155156
right = Math.max(right, box.x + box.width);
156157
}
157-
return annotationRailX(props.page.width, right, props.zoom);
158+
return annotationRailX(props.page.width, right, props.zoom, props.crop);
158159
});
159160
watch(noteRail, (x) => emit('rail', x), { immediate: true });
160161
function baseNoteRect(a) {
@@ -163,13 +164,15 @@ function baseNoteRect(a) {
163164
box = translated ? b?.translatedBox || b : b?.sourceBox || b;
164165
const line = a.rects[0],
165166
anchor = box || line,
166-
markerSize = 34 / props.zoom;
167+
markerSize = 34 / props.zoom,
168+
top = (props.page.height * props.crop.y) / 2,
169+
bottom = props.page.height - top;
167170
return {
168171
x: noteRail.value,
169172
y: Math.max(
170-
0,
173+
top,
171174
Math.min(
172-
props.page.height - markerSize,
175+
bottom - markerSize,
173176
anchor.y + (Math.min(line.height, anchor.height) - markerSize) / 2,
174177
),
175178
),
@@ -189,6 +192,7 @@ const notePositions = computed(() =>
189192
.map((a) => ({ id: a.id, y: baseNoteRect(a).y })),
190193
props.page.height,
191194
props.zoom,
195+
props.crop.y,
192196
),
193197
);
194198
function noteRect(a) {

‎src/features/annotations/annotation-display.mjs‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,32 @@ export function annotationLineRects(rects) {
3030
}
3131

3232
// Keep every marker on the same page rail, with a screen-space edge inset.
33-
export function annotationRailX(pageWidth, textRight, zoom) {
33+
export function annotationRailX(pageWidth, textRight, zoom, crop = { x: 0, y: 0 }) {
3434
const size = 34 / zoom,
3535
gap = 8 / zoom,
36-
limit = Math.max(0, pageWidth - size - gap);
37-
return Math.max(0, Math.min(limit, textRight + gap));
36+
left = (pageWidth * crop.x) / 2,
37+
right = pageWidth - left,
38+
limit = Math.max(left, right - size - gap);
39+
return crop.x ? limit : Math.max(left, Math.min(limit, textRight + gap));
3840
}
3941

4042
// Pack markers down the shared rail, then pull back from the page bottom.
41-
export function annotationNotePositions(notes, pageHeight, zoom) {
43+
export function annotationNotePositions(notes, pageHeight, zoom, cropY = 0) {
4244
const sorted = notes
4345
.map((n) => ({ ...n }))
4446
.sort((a, b) => a.y - b.y || String(a.id).localeCompare(String(b.id)));
4547
const size = 34 / zoom,
46-
gap = 6 / zoom;
48+
gap = 6 / zoom,
49+
top = (pageHeight * cropY) / 2,
50+
bottom = pageHeight - top;
4751
for (let i = 0; i < sorted.length; i++)
48-
sorted[i].y = Math.max(sorted[i].y, i ? sorted[i - 1].y + size + gap : 0);
52+
sorted[i].y = Math.max(sorted[i].y, i ? sorted[i - 1].y + size + gap : top);
4953
if (sorted.length) {
50-
sorted.at(-1).y = Math.min(sorted.at(-1).y, Math.max(0, pageHeight - size));
54+
sorted.at(-1).y = Math.min(sorted.at(-1).y, Math.max(top, bottom - size));
5155
for (let i = sorted.length - 2; i >= 0; i--)
5256
sorted[i].y = Math.min(sorted[i].y, sorted[i + 1].y - size - gap);
5357
}
54-
return new Map(sorted.map((n) => [n.id, Math.max(0, n.y)]));
58+
return new Map(sorted.map((n) => [n.id, Math.max(top, n.y)]));
5559
}
5660

5761
// Shared search and annotation spacing in screen pixels.

‎src/features/developer/DeveloperOptions.vue‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,17 @@ async function toggleDeveloper(enabled) {
8888
/>
8989
</div>
9090
<p class="muted">{{ dt('developerHint') }}</p>
91-
<AppButton v-if="developerEnabled" :disabled="developerBusy" @click="toggleDeveloper(true)">{{
92-
dt('openWindow')
93-
}}</AppButton>
94-
<AppButton class="install-guide-trigger" @click="openInstallGuide">{{
95-
zh() ? '调试安装引导…' : 'Debug installation guide…'
96-
}}</AppButton>
91+
<div class="developer-entry-actions">
92+
<AppButton
93+
v-if="developerEnabled"
94+
:disabled="developerBusy"
95+
@click="toggleDeveloper(true)"
96+
>{{ dt('openWindow') }}</AppButton
97+
>
98+
<AppButton class="install-guide-trigger" @click="openInstallGuide">{{
99+
zh() ? '调试安装引导…' : 'Debug installation guide…'
100+
}}</AppButton>
101+
</div>
97102
<Teleport to="body">
98103
<dialog
99104
ref="installDialog"
@@ -146,8 +151,18 @@ async function toggleDeveloper(enabled) {
146151
margin-bottom: 8px;
147152
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
148153
}
149-
.install-guide-trigger {
150-
margin-top: 8px;
154+
.developer-entry-actions {
155+
display: flex;
156+
flex-wrap: wrap;
157+
align-items: center;
158+
gap: 8px;
159+
padding-block: 4px;
160+
}
161+
.developer-entry-actions > :deep(*) {
162+
flex: 0 1 auto;
163+
width: auto;
164+
max-width: 100%;
165+
margin: 0;
151166
}
152167
.install-guide-dialog {
153168
width: min(560px, calc(100vw - 40px));

‎src/features/reader/FormulaOcrRegion.vue‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script setup>
2-
import { inject, ref, computed, onBeforeUnmount } from 'vue';
2+
import { inject, ref, computed, onBeforeUnmount, useId } from 'vue';
33
import { formulaOcrLabel } from './formula-ocr-labels.mjs';
44
const props = defineProps({ source: Function, box: Object, zoom: Number });
55
const ocr = inject('formulaOcr', null);
66
const busy = ref(false);
7+
const hintId = useId();
78
const enabled = computed(() => ocr?.enabled.value === true);
89
const controller = new AbortController();
910
let rendering,
@@ -65,7 +66,7 @@ onBeforeUnmount(() => {
6566
<button
6667
class="formula-ocr-button"
6768
:disabled="busy"
68-
:title="formulaOcrLabel('recognize')"
69+
:aria-describedby="hintId"
6970
:aria-label="formulaOcrLabel(busy ? 'recognizing' : 'recognize')"
7071
:aria-busy="busy"
7172
@click.stop="recognize"
@@ -79,6 +80,9 @@ onBeforeUnmount(() => {
7980
aria-hidden="true"
8081
></span>
8182
</button>
83+
<span :id="hintId" class="formula-ocr-hint" role="tooltip">
84+
{{ formulaOcrLabel(busy ? 'recognizing' : 'recognize') }}
85+
</span>
8286
</div>
8387
</div>
8488
</template>
@@ -123,6 +127,29 @@ onBeforeUnmount(() => {
123127
cursor: pointer;
124128
pointer-events: auto;
125129
}
130+
.formula-ocr-hint {
131+
position: absolute;
132+
right: 3px;
133+
top: 39px;
134+
z-index: 1;
135+
padding: 5px 9px;
136+
border: 1px solid var(--separator, #8883);
137+
border-radius: 7px;
138+
background: var(--surface, #f5f5f5);
139+
color: var(--text);
140+
box-shadow: 0 3px 12px #0002;
141+
font-size: 12px;
142+
line-height: 1.4;
143+
white-space: nowrap;
144+
opacity: 0;
145+
visibility: hidden;
146+
pointer-events: none;
147+
}
148+
.formula-ocr-button:hover + .formula-ocr-hint,
149+
.formula-ocr-button:focus-visible + .formula-ocr-hint {
150+
opacity: 1;
151+
visibility: visible;
152+
}
126153
.formula-ocr-button progress {
127154
width: 19px;
128155
height: 5px;

0 commit comments

Comments
 (0)