Skip to content

Commit 39b310a

Browse files
committed
feat: experimental topic sentences in outline
Lists the first sentence of each long body paragraph under the deepest outline entry that precedes it. Off by default under Settings > Experimental > General; page text is read lazily while the outline is visible, and Jev rejections are hidden when topic sentence checks are on.
1 parent f075160 commit 39b310a

13 files changed

Lines changed: 478 additions & 37 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const SETTINGS_PREFERENCES = [
4242
'documentLanguageDetection',
4343
'topicSentenceDetection',
4444
'tocGenerationEnabled',
45+
'outlineTopicSentencesEnabled',
4546
'translationCompareEnabled',
4647
'watermarkRemovalEnabled',
4748
'recentMagicZoomEnabled',

‎electron/main/services/preferences.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const DEFAULT_PREFERENCES = Object.freeze({
8787
documentLanguageDetection: false,
8888
topicSentenceDetection: false,
8989
tocGenerationEnabled: false,
90+
outlineTopicSentencesEnabled: false,
9091
translationCompareEnabled: false,
9192
watermarkRemovalEnabled: false,
9293
recentMagicZoomEnabled: false,
@@ -340,6 +341,7 @@ const VALIDATORS = {
340341
documentLanguageDetection: (value) => typeof value === 'boolean',
341342
topicSentenceDetection: (value) => typeof value === 'boolean',
342343
tocGenerationEnabled: (value) => typeof value === 'boolean',
344+
outlineTopicSentencesEnabled: (value) => typeof value === 'boolean',
343345
translationCompareEnabled: (value) => typeof value === 'boolean',
344346
watermarkRemovalEnabled: (value) => typeof value === 'boolean',
345347
recentMagicZoomEnabled: (value) => typeof value === 'boolean',
@@ -408,6 +410,7 @@ const VALIDATION_MESSAGES = {
408410
documentLanguageDetection: 'Invalid document language detection preference',
409411
topicSentenceDetection: 'Invalid topic sentence detection preference',
410412
tocGenerationEnabled: 'Invalid contents generation preference',
413+
outlineTopicSentencesEnabled: 'Invalid outline topic sentences preference',
411414
translationCompareEnabled: 'Invalid translation compare preference',
412415
watermarkRemovalEnabled: 'Invalid watermark removal preference',
413416
recentMagicZoomEnabled: 'Invalid recent magic zoom preference',

‎src/app/useReaderWindow.mjs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createMotionState } from '../features/reader/motion-state.mjs';
1414
import { createTranslationQueueState } from '../features/translation/queue-state.mjs';
1515
import { createPreferenceState } from '../features/settings/preference-state.mjs';
1616
import { createTopicSentenceClassifier } from '../features/translation/topic-sentence-classifier.mjs';
17+
import { useOutlineTopicSentences } from '../features/reader/outline-topic-sentences.mjs';
1718
import { createReaderFeatures } from './reader-features.mjs';
1819

1920
import { menuLabel } from '../../shared/i18n/menu.mjs';
@@ -330,7 +331,9 @@ export function useReaderWindow() {
330331
context: () => ({
331332
document: session.pdf,
332333
epoch: session.epoch,
333-
enabled: preferences.topicSentenceDetection.value && emphasizeTopicSentences.value,
334+
enabled:
335+
preferences.topicSentenceDetection.value &&
336+
(emphasizeTopicSentences.value || preferences.outlineTopicSentencesEnabled.value),
334337
token: preferences.jevApiToken.value,
335338
}),
336339
api: featureActions.api,
@@ -341,7 +344,12 @@ export function useReaderWindow() {
341344
isTopicSentence: topicSentenceClassifier.isTopicSentence,
342345
});
343346
watch(
344-
[preferences.topicSentenceDetection, preferences.jevApiToken, emphasizeTopicSentences],
347+
[
348+
preferences.topicSentenceDetection,
349+
preferences.jevApiToken,
350+
emphasizeTopicSentences,
351+
preferences.outlineTopicSentencesEnabled,
352+
],
345353
() => {
346354
topicSentenceClassifier.invalidate();
347355
topicSentenceRevision.value++;
@@ -434,6 +442,17 @@ export function useReaderWindow() {
434442
model = ref(''),
435443
reading = ref(t('reading.ready')),
436444
showTranslations = ref(true);
445+
provide(
446+
'outlineTopicSentences',
447+
useOutlineTopicSentences({
448+
session,
449+
pages,
450+
outline: documentOutline,
451+
enabled: preferences.outlineTopicSentencesEnabled,
452+
visible: computed(() => sidebar.value && sidebarMode.value === 'outline'),
453+
locale: uiLanguage,
454+
}),
455+
);
437456
window.previewCurrentFileKind = () =>
438457
showTranslations.value &&
439458
pages.value.some((page) => !!page.mathDocument || hasParagraphTranslation(page))

‎src/features/reader/SidebarNavigation.vue‎

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup>
2-
import { computed, watch, ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
2+
import { computed, watch, ref, nextTick, onMounted, onBeforeUnmount, inject } from 'vue';
33
import { t, uiLanguage } from '../../i18n/index.mjs';
44
import { annotationSections, annotationChapters } from '../annotations/annotation-browser.mjs';
55
import { platform, AppSegmentedControl, AppSegment } from '../../ui/controls.mjs';
@@ -79,8 +79,28 @@ const visibleOutline = computed(() =>
7979
),
8080
),
8181
);
82+
// Experimental: first sentences of body paragraphs, keyed by the outline entry
83+
// that owns them. The optional Jev classifier can reject non-topic sentences.
84+
const outlineTopicSentences = inject('outlineTopicSentences', null);
85+
const topicSentenceClassifier = inject('topicSentenceClassifier', null);
86+
const topicSentences = computed(() => {
87+
const sections = outlineTopicSentences?.value;
88+
if (!sections?.size || props.mode !== 'outline') return new Map();
89+
const classify = topicSentenceClassifier?.isTopicSentence;
90+
void topicSentenceClassifier?.revision?.value;
91+
if (typeof classify !== 'function') return sections;
92+
const kept = new Map();
93+
for (const [id, items] of sections) {
94+
const accepted = items.filter((item) => classify(item.paragraph, item.sentence) !== false);
95+
if (accepted.length) kept.set(id, accepted);
96+
}
97+
return kept;
98+
});
8299
function hasChildren(item) {
83-
return props.outline.some((child) => child.id.startsWith(item.id + '.'));
100+
return (
101+
topicSentences.value.has(item.id) ||
102+
props.outline.some((child) => child.id.startsWith(item.id + '.'))
103+
);
84104
}
85105
function toggleOutline(item) {
86106
const next = new Set(collapsed.value);
@@ -289,37 +309,52 @@ watch(
289309
</button>
290310
</template>
291311
</div>
292-
<div
293-
v-for="item in visibleOutline"
294-
:key="item.id"
295-
class="sidebar-outline-row"
296-
:style="{ paddingLeft: 6 + Math.min(item.depth, 8) * 12 + 'px' }"
297-
>
298-
<button
299-
v-if="hasChildren(item)"
300-
class="sidebar-outline-toggle"
301-
:aria-expanded="!collapsed.has(item.id)"
302-
:aria-label="
303-
t(collapsed.has(item.id) ? 'sidebar.expand' : 'sidebar.collapse', { title: item.title })
304-
"
305-
@click="toggleOutline(item)"
312+
<template v-for="item in visibleOutline" :key="item.id"
313+
><div
314+
class="sidebar-outline-row"
315+
:style="{ paddingLeft: 6 + Math.min(item.depth, 8) * 12 + 'px' }"
306316
>
307-
<svg viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" /></svg></button
308-
><span v-else class="sidebar-outline-spacer"></span>
309-
<button
310-
class="sidebar-outline-item"
311-
:class="{
312-
'sidebar-outline-top-level': hasNestedOutline && item.depth === 0,
313-
'sidebar-outline-read': readChapters.has(item.id),
314-
}"
315-
:disabled="!item.page && !hasChildren(item)"
316-
@click="item.page && $event.detail < 2 && emit('page', item.page)"
317-
@dblclick="hasChildren(item) && toggleOutline(item)"
318-
>
319-
<span>{{ item.title }}</span
320-
><small v-if="item.page">{{ item.page }}</small>
321-
</button>
322-
</div>
317+
<button
318+
v-if="hasChildren(item)"
319+
class="sidebar-outline-toggle"
320+
:aria-expanded="!collapsed.has(item.id)"
321+
:aria-label="
322+
t(collapsed.has(item.id) ? 'sidebar.expand' : 'sidebar.collapse', {
323+
title: item.title,
324+
})
325+
"
326+
@click="toggleOutline(item)"
327+
>
328+
<svg viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" /></svg></button
329+
><span v-else class="sidebar-outline-spacer"></span>
330+
<button
331+
class="sidebar-outline-item"
332+
:class="{
333+
'sidebar-outline-top-level': hasNestedOutline && item.depth === 0,
334+
'sidebar-outline-read': readChapters.has(item.id),
335+
}"
336+
:disabled="!item.page && !hasChildren(item)"
337+
@click="item.page && $event.detail < 2 && emit('page', item.page)"
338+
@dblclick="hasChildren(item) && toggleOutline(item)"
339+
>
340+
<span>{{ item.title }}</span
341+
><small v-if="item.page">{{ item.page }}</small>
342+
</button>
343+
</div>
344+
<template v-if="!collapsed.has(item.id)">
345+
<button
346+
v-for="topic in topicSentences.get(item.id)"
347+
:key="topic.id"
348+
class="sidebar-outline-topic"
349+
:style="{ paddingLeft: 28 + Math.min(item.depth + 1, 8) * 12 + 'px' }"
350+
:title="topic.sentence"
351+
@click="emit('page', topic.page)"
352+
>
353+
<span>{{ topic.sentence }}</span
354+
><small>{{ topic.page }}</small>
355+
</button>
356+
</template></template
357+
>
323358
</div>
324359
<div v-show="mode === 'annotations'" class="sidebar-annotations-view">
325360
<div class="annotation-browser-controls">
@@ -616,6 +651,36 @@ watch(
616651
.sidebar-outline-top-level span {
617652
font-weight: 700;
618653
}
654+
.sidebar-outline-topic {
655+
display: flex;
656+
gap: 8px;
657+
width: 100%;
658+
box-sizing: border-box;
659+
border: 0;
660+
border-radius: 6px;
661+
background: transparent;
662+
padding: 4px 8px;
663+
color: var(--text-secondary);
664+
font-size: 12px;
665+
line-height: 1.4;
666+
text-align: left;
667+
cursor: pointer;
668+
}
669+
.sidebar-outline-topic span {
670+
flex: 1;
671+
overflow: hidden;
672+
display: -webkit-box;
673+
-webkit-line-clamp: 2;
674+
-webkit-box-orient: vertical;
675+
overflow-wrap: anywhere;
676+
}
677+
.sidebar-outline-topic small {
678+
flex-shrink: 0;
679+
}
680+
.sidebar-outline-topic:hover {
681+
background: var(--chrome-pressed);
682+
color: var(--text);
683+
}
619684
.sidebar-outline-item.sidebar-outline-read {
620685
color: var(--text-secondary);
621686
}

0 commit comments

Comments
 (0)