narrarium-astro-reader 0.1.22 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/components/ReaderRuntime.astro +82 -2
- package/src/layouts/BaseLayout.astro +1 -0
- package/src/pages/chapters/[chapter].astro +175 -41
- package/src/styles/global.css +110 -3
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ npx narrarium-astro-reader reader --book-root .. --package-name my-book-reader
|
|
|
28
28
|
- live search across canon, chapters, and scenes
|
|
29
29
|
- character, location, faction, item, secret, and timeline indexes
|
|
30
30
|
- canon mention popups, backlinks, and asset rendering for book, entity, chapter, and scene art
|
|
31
|
+
- a Full read chapter mode that opens an immersive browser-fullscreen reading view with continuous prose, auto-hiding minimal previous/jump/next navigation, a small ghost hint for rediscovering controls, and canon popups still available
|
|
32
|
+
- dismissible Read Aloud controls that can be reopened from a subtle masthead speaker button and stay out of the way during Full read mode
|
|
31
33
|
- automatic EPUB export to `public/downloads/book.epub`
|
|
32
34
|
- live watcher for book markdown, canon, and assets during `npm run dev`
|
|
33
35
|
- `npm run doctor` for broken references, spoiler thresholds, asset metadata, and stale `plot.md`, `resumes/`, or `state/`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "narrarium-astro-reader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"test": "npm run build:cli && node --test test/**/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"narrarium": "^0.1.
|
|
53
|
+
"narrarium": "^0.1.23",
|
|
54
54
|
"astro": "^5.14.1",
|
|
55
55
|
"chokidar": "^4.0.3",
|
|
56
56
|
"marked": "^16.3.0"
|
|
@@ -121,7 +121,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
121
121
|
<p class="eyebrow">Read Aloud</p>
|
|
122
122
|
<p class="tts-player__title" data-tts-title>Ready</p>
|
|
123
123
|
</div>
|
|
124
|
-
<
|
|
124
|
+
<div class="tts-player__top-actions">
|
|
125
|
+
<span class="chip" data-tts-badge>Browser voice</span>
|
|
126
|
+
<button type="button" class="icon-btn" data-tts-close aria-label="Hide read aloud controls">x</button>
|
|
127
|
+
</div>
|
|
125
128
|
</div>
|
|
126
129
|
<p class="tts-player__label" data-tts-label>Choose a chapter or scene to start reading aloud.</p>
|
|
127
130
|
<div class="tts-player__controls">
|
|
@@ -156,6 +159,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
156
159
|
const ttsVoiceStorageKey = "narrarium-reader-tts-voice";
|
|
157
160
|
const ttsRateStorageKey = "narrarium-reader-tts-rate";
|
|
158
161
|
const ttsProgressStorageKey = "narrarium-reader-tts-progress";
|
|
162
|
+
const ttsPlayerDismissedStorageKey = "narrarium-reader-tts-player-dismissed";
|
|
159
163
|
const glossaryDataElement = document.getElementById("canon-glossary-data");
|
|
160
164
|
const chapterDataElement = document.getElementById("reader-chapter-data");
|
|
161
165
|
const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
|
|
@@ -657,12 +661,12 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
657
661
|
const player = document.querySelector("[data-tts-player]");
|
|
658
662
|
const roots = [...document.querySelectorAll("[data-tts-root]")];
|
|
659
663
|
const triggers = [...document.querySelectorAll("[data-tts-trigger]")];
|
|
664
|
+
const panelToggle = document.querySelector("[data-tts-panel-toggle]");
|
|
660
665
|
if (!(player instanceof HTMLElement) || roots.length === 0) {
|
|
661
666
|
return;
|
|
662
667
|
}
|
|
663
668
|
|
|
664
669
|
document.body.classList.add("tts-enabled");
|
|
665
|
-
player.hidden = false;
|
|
666
670
|
triggers.forEach((trigger) => {
|
|
667
671
|
if (trigger instanceof HTMLElement) {
|
|
668
672
|
trigger.hidden = false;
|
|
@@ -674,12 +678,17 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
674
678
|
return;
|
|
675
679
|
}
|
|
676
680
|
|
|
681
|
+
if (panelToggle instanceof HTMLButtonElement) {
|
|
682
|
+
panelToggle.hidden = false;
|
|
683
|
+
}
|
|
684
|
+
|
|
677
685
|
const titleElement = player.querySelector("[data-tts-title]");
|
|
678
686
|
const labelElement = player.querySelector("[data-tts-label]");
|
|
679
687
|
const badgeElement = player.querySelector("[data-tts-badge]");
|
|
680
688
|
const statusElement = player.querySelector("[data-tts-status]");
|
|
681
689
|
const playToggle = player.querySelector("[data-tts-play-toggle]");
|
|
682
690
|
const stopButton = player.querySelector("[data-tts-stop]");
|
|
691
|
+
const closeButton = player.querySelector("[data-tts-close]");
|
|
683
692
|
const voiceSelect = player.querySelector("[data-tts-voice]");
|
|
684
693
|
const rateInput = player.querySelector("[data-tts-rate]");
|
|
685
694
|
|
|
@@ -690,6 +699,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
690
699
|
!(statusElement instanceof HTMLElement) ||
|
|
691
700
|
!(playToggle instanceof HTMLButtonElement) ||
|
|
692
701
|
!(stopButton instanceof HTMLButtonElement) ||
|
|
702
|
+
!(closeButton instanceof HTMLButtonElement) ||
|
|
693
703
|
!(voiceSelect instanceof HTMLSelectElement) ||
|
|
694
704
|
!(rateInput instanceof HTMLInputElement)
|
|
695
705
|
) {
|
|
@@ -709,14 +719,19 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
709
719
|
runId: 0,
|
|
710
720
|
speaking: false,
|
|
711
721
|
paused: false,
|
|
722
|
+
focusMode: document.body.classList.contains("is-focus-mode"),
|
|
723
|
+
dismissed: readStorage(ttsPlayerDismissedStorageKey) === "true",
|
|
712
724
|
titleElement,
|
|
713
725
|
labelElement,
|
|
714
726
|
badgeElement,
|
|
715
727
|
statusElement,
|
|
716
728
|
playToggle,
|
|
717
729
|
stopButton,
|
|
730
|
+
closeButton,
|
|
718
731
|
voiceSelect,
|
|
719
732
|
rateInput,
|
|
733
|
+
player,
|
|
734
|
+
panelToggle,
|
|
720
735
|
};
|
|
721
736
|
|
|
722
737
|
const savedProgress = readJson(ttsProgressStorageKey);
|
|
@@ -739,10 +754,24 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
739
754
|
window.speechSynthesis.onvoiceschanged = () => populateVoiceOptions(state);
|
|
740
755
|
}
|
|
741
756
|
|
|
757
|
+
if (panelToggle instanceof HTMLButtonElement) {
|
|
758
|
+
panelToggle.addEventListener("click", () => {
|
|
759
|
+
if (state.player.hidden) {
|
|
760
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
761
|
+
updateTtsUi(state);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
hideTtsPlayer(state, { persistDismissed: true });
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
742
769
|
window.addEventListener("beforeunload", () => {
|
|
743
770
|
state.runId += 1;
|
|
744
771
|
state.speech.cancel();
|
|
745
772
|
});
|
|
773
|
+
|
|
774
|
+
hideTtsPlayer(state, { persistDismissed: false });
|
|
746
775
|
}
|
|
747
776
|
|
|
748
777
|
function buildTtsScopes(roots) {
|
|
@@ -883,6 +912,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
883
912
|
|
|
884
913
|
trigger.dataset.defaultLabel = trigger.textContent || "Read aloud";
|
|
885
914
|
trigger.addEventListener("click", () => {
|
|
915
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
886
916
|
const scopeId = trigger.dataset.ttsTrigger || "chapter";
|
|
887
917
|
startTtsScope(state, scopeId);
|
|
888
918
|
updateTtsTriggerButtons(triggers, state);
|
|
@@ -892,6 +922,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
892
922
|
|
|
893
923
|
function bindTtsPlayerControls(state) {
|
|
894
924
|
state.playToggle.addEventListener("click", () => {
|
|
925
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
895
926
|
if (state.speech.speaking && !state.speech.paused) {
|
|
896
927
|
state.speech.pause();
|
|
897
928
|
state.paused = true;
|
|
@@ -914,6 +945,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
914
945
|
updateTtsUi(state);
|
|
915
946
|
});
|
|
916
947
|
|
|
948
|
+
state.closeButton.addEventListener("click", () => {
|
|
949
|
+
hideTtsPlayer(state, { persistDismissed: true });
|
|
950
|
+
});
|
|
951
|
+
|
|
917
952
|
state.voiceSelect.addEventListener("change", () => {
|
|
918
953
|
state.voiceUri = state.voiceSelect.value;
|
|
919
954
|
writeStorage(ttsVoiceStorageKey, state.voiceUri);
|
|
@@ -937,6 +972,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
937
972
|
window.addEventListener("narrarium:tts-start", (event) => {
|
|
938
973
|
const detail = event instanceof CustomEvent ? event.detail || {} : {};
|
|
939
974
|
const scopeId = typeof detail.scopeId === "string" ? detail.scopeId : "chapter";
|
|
975
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
940
976
|
startTtsScope(state, scopeId, { fromStart: Boolean(detail.fromStart) });
|
|
941
977
|
updateTtsTriggerButtons(state.triggers, state);
|
|
942
978
|
});
|
|
@@ -946,6 +982,15 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
946
982
|
stopTts(state, detail.keepProgress !== false);
|
|
947
983
|
updateTtsUi(state);
|
|
948
984
|
});
|
|
985
|
+
|
|
986
|
+
window.addEventListener("narrarium:focus-mode-change", (event) => {
|
|
987
|
+
const detail = event instanceof CustomEvent ? event.detail || {} : {};
|
|
988
|
+
state.focusMode = Boolean(detail.enabled);
|
|
989
|
+
if (state.focusMode) {
|
|
990
|
+
hideTtsPlayer(state, { persistDismissed: false });
|
|
991
|
+
}
|
|
992
|
+
syncTtsPanelToggle(state);
|
|
993
|
+
});
|
|
949
994
|
}
|
|
950
995
|
|
|
951
996
|
function populateVoiceOptions(state) {
|
|
@@ -1156,6 +1201,41 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
1156
1201
|
updateTtsTriggerButtons(state.triggers, state);
|
|
1157
1202
|
}
|
|
1158
1203
|
|
|
1204
|
+
function showTtsPlayer(state, options) {
|
|
1205
|
+
if (state.focusMode) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
state.dismissed = false;
|
|
1210
|
+
state.player.hidden = false;
|
|
1211
|
+
syncTtsPanelToggle(state);
|
|
1212
|
+
if (options?.persistDismissed === false) {
|
|
1213
|
+
removeStorage(ttsPlayerDismissedStorageKey);
|
|
1214
|
+
} else {
|
|
1215
|
+
writeStorage(ttsPlayerDismissedStorageKey, "false");
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function hideTtsPlayer(state, options) {
|
|
1220
|
+
state.player.hidden = true;
|
|
1221
|
+
state.dismissed = true;
|
|
1222
|
+
syncTtsPanelToggle(state);
|
|
1223
|
+
if (options?.persistDismissed) {
|
|
1224
|
+
writeStorage(ttsPlayerDismissedStorageKey, "true");
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function syncTtsPanelToggle(state) {
|
|
1229
|
+
if (!(state.panelToggle instanceof HTMLButtonElement)) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const expanded = !state.player.hidden && !state.focusMode;
|
|
1234
|
+
state.panelToggle.setAttribute("aria-pressed", expanded ? "true" : "false");
|
|
1235
|
+
state.panelToggle.setAttribute("aria-label", expanded ? "Hide read aloud controls" : "Open read aloud controls");
|
|
1236
|
+
state.panelToggle.textContent = expanded ? "🔈" : "🔊";
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1159
1239
|
function highlightTtsSegment(segment) {
|
|
1160
1240
|
clearTtsHighlight();
|
|
1161
1241
|
segment.classList.add("is-speaking");
|
|
@@ -86,6 +86,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
86
86
|
</nav>
|
|
87
87
|
<div class="masthead-actions">
|
|
88
88
|
<a class="chip reader-status" data-continue-link href="./" hidden>Continue reading</a>
|
|
89
|
+
<button type="button" class="masthead-btn" data-tts-panel-toggle aria-label="Open read aloud controls" aria-pressed="false" hidden>🔊</button>
|
|
89
90
|
<button type="button" class="masthead-btn" data-search-toggle aria-label="Search">🔍</button>
|
|
90
91
|
<button type="button" class="masthead-btn" data-reader-settings-toggle aria-label="Open reading settings">⚙</button>
|
|
91
92
|
</div>
|
|
@@ -31,10 +31,10 @@ const currentIndex = allChapters.findIndex((entry) => entry.slug === chapterSlug
|
|
|
31
31
|
const previousChapter = currentIndex > 0 ? allChapters[currentIndex - 1] : null;
|
|
32
32
|
const nextChapter = currentIndex >= 0 && currentIndex < allChapters.length - 1 ? allChapters[currentIndex + 1] : null;
|
|
33
33
|
const chapterHtml = await marked.parse(chapter.body);
|
|
34
|
-
const chapterMetaEntries = [
|
|
35
|
-
["Point of view", chapter.metadata.pov],
|
|
36
|
-
["Timeline", chapter.metadata.timeline_ref],
|
|
37
|
-
["Tags", chapter.metadata.tags],
|
|
34
|
+
const chapterMetaEntries: Array<[string, unknown]> = [
|
|
35
|
+
["Point of view", chapter.metadata.pov] as [string, unknown],
|
|
36
|
+
["Timeline", chapter.metadata.timeline_ref] as [string, unknown],
|
|
37
|
+
["Tags", chapter.metadata.tags] as [string, unknown],
|
|
38
38
|
].filter(([, value]) => value !== undefined && value !== null && (!Array.isArray(value) || value.length > 0));
|
|
39
39
|
const chapterId = String(chapter.metadata.id);
|
|
40
40
|
const chapterRelatedLinks = await loadRelatedCanonLinks(chapterId, [chapter.metadata.pov, chapter.metadata.timeline_ref]);
|
|
@@ -73,7 +73,7 @@ const renderedParagraphs = await Promise.all(
|
|
|
73
73
|
</div>
|
|
74
74
|
|
|
75
75
|
<section class="section chapter-reading-frame">
|
|
76
|
-
<div class="chapter-focus-banner">
|
|
76
|
+
<div class="chapter-focus-banner focus-hidden">
|
|
77
77
|
<p class="eyebrow">Full read</p>
|
|
78
78
|
<h2>{chapter.metadata.title}</h2>
|
|
79
79
|
<p class="chapter-meta" data-reader-current-scene>Chapter opening</p>
|
|
@@ -195,42 +195,81 @@ const renderedParagraphs = await Promise.all(
|
|
|
195
195
|
<p class="eyebrow">Full read</p>
|
|
196
196
|
<div class="focus-rail__title">{chapter.metadata.title}</div>
|
|
197
197
|
</div>
|
|
198
|
-
<div class="focus-rail__status" data-reader-current-scene>Chapter opening</div>
|
|
199
198
|
</div>
|
|
200
199
|
<div class="focus-rail__controls">
|
|
201
200
|
<a class:list={["focus-rail__link", !previousChapter && "is-disabled"]} href={previousChapter ? `chapters/${previousChapter.slug}/` : undefined} aria-disabled={!previousChapter}>Previous</a>
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
<label class="focus-rail__jump">
|
|
202
|
+
<span class="sr-only">Jump to chapter</span>
|
|
203
|
+
<select data-reader-focus-jump aria-label="Jump to chapter">
|
|
204
|
+
{allChapters.map((entry) => (
|
|
205
|
+
<option value={entry.slug} selected={entry.slug === chapterSlug}>
|
|
206
|
+
{`Chapter ${String(entry.metadata.number).padStart(3, "0")} - ${entry.metadata.title}`}
|
|
207
|
+
</option>
|
|
208
|
+
))}
|
|
209
|
+
</select>
|
|
210
|
+
</label>
|
|
205
211
|
<a class:list={["focus-rail__link", !nextChapter && "is-disabled"]} href={nextChapter ? `chapters/${nextChapter.slug}/` : undefined} aria-disabled={!nextChapter}>Next</a>
|
|
206
212
|
<button type="button" class="focus-rail__button" data-reader-focus-toggle>Exit</button>
|
|
207
213
|
</div>
|
|
208
214
|
</aside>
|
|
209
215
|
|
|
216
|
+
<div class="focus-hint" data-reader-focus-hint aria-hidden="true">
|
|
217
|
+
Move the mouse, tap, or press a key to show controls.
|
|
218
|
+
</div>
|
|
219
|
+
|
|
210
220
|
<script is:inline>
|
|
211
221
|
(() => {
|
|
212
222
|
const focusModeKey = "narrarium-reader-focus-mode";
|
|
213
223
|
const voiceMarkerKey = "narrarium-reader-voice-marker";
|
|
224
|
+
const focusRailHideDelayMs = 1800;
|
|
225
|
+
const focusHintHideDelayMs = 2600;
|
|
214
226
|
const root = document.body;
|
|
215
227
|
const chapterPath = window.location.pathname;
|
|
228
|
+
const chapterSegments = chapterPath.split("/").filter(Boolean);
|
|
229
|
+
const currentChapterSlug = chapterSegments[chapterSegments.length - 1] || "";
|
|
230
|
+
const fullscreenTarget = document.documentElement;
|
|
216
231
|
const toggleButtons = [...document.querySelectorAll("[data-reader-focus-toggle]")];
|
|
217
232
|
const markerButtons = [...document.querySelectorAll("[data-reader-marker-set]")];
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
233
|
+
const chapterJumpSelect = document.querySelector("[data-reader-focus-jump]");
|
|
234
|
+
const focusRail = document.querySelector(".focus-rail");
|
|
235
|
+
const focusHint = document.querySelector("[data-reader-focus-hint]");
|
|
221
236
|
const currentSceneLabels = [...document.querySelectorAll("[data-reader-current-scene]")];
|
|
222
237
|
const sceneElements = [...document.querySelectorAll("[data-reader-scene]")];
|
|
238
|
+
let focusRailHideTimer = null;
|
|
239
|
+
let focusHintHideTimer = null;
|
|
240
|
+
let railPointerInside = false;
|
|
241
|
+
let focusHintShownForSession = false;
|
|
223
242
|
|
|
224
|
-
applyFocusMode(readStorage(focusModeKey) === "true", false);
|
|
243
|
+
applyFocusMode(readStorage(focusModeKey) === "true", false, { allowFullscreenRequest: false });
|
|
225
244
|
updateMarkerUi(readMarker());
|
|
226
245
|
updateActiveScene();
|
|
227
246
|
|
|
228
247
|
window.addEventListener("scroll", scheduleSceneRefresh, { passive: true });
|
|
229
248
|
window.addEventListener("resize", scheduleSceneRefresh);
|
|
249
|
+
document.addEventListener("pointermove", handleFocusRailWake, { passive: true });
|
|
250
|
+
document.addEventListener("touchstart", handleFocusRailWake, { passive: true });
|
|
251
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
252
|
+
|
|
253
|
+
focusRail?.addEventListener("pointerenter", () => {
|
|
254
|
+
railPointerInside = true;
|
|
255
|
+
showFocusRail();
|
|
256
|
+
});
|
|
257
|
+
focusRail?.addEventListener("pointerleave", () => {
|
|
258
|
+
railPointerInside = false;
|
|
259
|
+
scheduleFocusRailHide();
|
|
260
|
+
});
|
|
261
|
+
focusRail?.addEventListener("focusin", () => {
|
|
262
|
+
showFocusRail();
|
|
263
|
+
});
|
|
264
|
+
focusRail?.addEventListener("focusout", () => {
|
|
265
|
+
window.requestAnimationFrame(() => {
|
|
266
|
+
scheduleFocusRailHide();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
230
269
|
|
|
231
270
|
toggleButtons.forEach((button) => {
|
|
232
271
|
button.addEventListener("click", () => {
|
|
233
|
-
applyFocusMode(!root.classList.contains("is-focus-mode"), true);
|
|
272
|
+
applyFocusMode(!root.classList.contains("is-focus-mode"), true, { allowFullscreenRequest: true });
|
|
234
273
|
});
|
|
235
274
|
});
|
|
236
275
|
|
|
@@ -246,20 +285,13 @@ const renderedParagraphs = await Promise.all(
|
|
|
246
285
|
});
|
|
247
286
|
});
|
|
248
287
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
markerPlayButton?.addEventListener("click", () => {
|
|
254
|
-
const marker = readMarker();
|
|
255
|
-
if (!marker || marker.path !== chapterPath || !marker.scopeId) {
|
|
288
|
+
chapterJumpSelect?.addEventListener("change", () => {
|
|
289
|
+
const nextSlug = chapterJumpSelect.value;
|
|
290
|
+
if (!nextSlug || nextSlug === currentChapterSlug) {
|
|
256
291
|
return;
|
|
257
292
|
}
|
|
258
|
-
startTtsFromScope(marker.scopeId);
|
|
259
|
-
});
|
|
260
293
|
|
|
261
|
-
|
|
262
|
-
window.dispatchEvent(new CustomEvent("narrarium:tts-stop", { detail: { keepProgress: true } }));
|
|
294
|
+
window.location.href = `chapters/${nextSlug}/`;
|
|
263
295
|
});
|
|
264
296
|
|
|
265
297
|
document.addEventListener("keydown", (event) => {
|
|
@@ -268,13 +300,17 @@ const renderedParagraphs = await Promise.all(
|
|
|
268
300
|
}
|
|
269
301
|
|
|
270
302
|
if (event.key === "Escape" && root.classList.contains("is-focus-mode")) {
|
|
271
|
-
applyFocusMode(false, true);
|
|
303
|
+
applyFocusMode(false, true, { allowFullscreenRequest: true });
|
|
304
|
+
return;
|
|
272
305
|
}
|
|
273
306
|
|
|
274
307
|
if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "f") {
|
|
275
308
|
event.preventDefault();
|
|
276
|
-
applyFocusMode(!root.classList.contains("is-focus-mode"), true);
|
|
309
|
+
applyFocusMode(!root.classList.contains("is-focus-mode"), true, { allowFullscreenRequest: true });
|
|
310
|
+
return;
|
|
277
311
|
}
|
|
312
|
+
|
|
313
|
+
handleFocusRailWake();
|
|
278
314
|
});
|
|
279
315
|
|
|
280
316
|
let refreshScheduled = false;
|
|
@@ -291,28 +327,130 @@ const renderedParagraphs = await Promise.all(
|
|
|
291
327
|
});
|
|
292
328
|
}
|
|
293
329
|
|
|
294
|
-
function applyFocusMode(enabled, persist) {
|
|
330
|
+
function applyFocusMode(enabled, persist, options = { allowFullscreenRequest: false }) {
|
|
295
331
|
root.classList.toggle("is-focus-mode", enabled);
|
|
332
|
+
window.dispatchEvent(new CustomEvent("narrarium:focus-mode-change", { detail: { enabled } }));
|
|
296
333
|
toggleButtons.forEach((button) => {
|
|
297
334
|
button.textContent = enabled ? "Exit full read" : "Full read";
|
|
298
335
|
button.setAttribute("aria-pressed", enabled ? "true" : "false");
|
|
299
336
|
});
|
|
337
|
+
if (enabled) {
|
|
338
|
+
resetFocusHintSession();
|
|
339
|
+
showFocusRailTemporarily();
|
|
340
|
+
} else {
|
|
341
|
+
clearFocusRailHideTimer();
|
|
342
|
+
clearFocusHintHideTimer();
|
|
343
|
+
focusRail?.classList.remove("is-hidden");
|
|
344
|
+
focusHint?.classList.remove("is-visible");
|
|
345
|
+
}
|
|
300
346
|
scheduleSceneRefresh();
|
|
301
347
|
|
|
302
348
|
if (persist) {
|
|
303
349
|
writeStorage(focusModeKey, enabled ? "true" : "false");
|
|
304
350
|
}
|
|
351
|
+
|
|
352
|
+
if (options.allowFullscreenRequest) {
|
|
353
|
+
void syncFullscreenForFocusMode(enabled);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handleFocusRailWake() {
|
|
358
|
+
if (!root.classList.contains("is-focus-mode")) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
hideFocusHint();
|
|
363
|
+
showFocusRailTemporarily();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function showFocusRailTemporarily() {
|
|
367
|
+
showFocusRail();
|
|
368
|
+
scheduleFocusRailHide();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function showFocusRail() {
|
|
372
|
+
clearFocusRailHideTimer();
|
|
373
|
+
focusRail?.classList.remove("is-hidden");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function scheduleFocusRailHide() {
|
|
377
|
+
clearFocusRailHideTimer();
|
|
378
|
+
|
|
379
|
+
if (!root.classList.contains("is-focus-mode") || railPointerInside || focusRail?.matches(":focus-within")) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
focusRailHideTimer = window.setTimeout(() => {
|
|
384
|
+
if (!railPointerInside && !focusRail?.matches(":focus-within") && root.classList.contains("is-focus-mode")) {
|
|
385
|
+
focusRail?.classList.add("is-hidden");
|
|
386
|
+
showFocusHintOnce();
|
|
387
|
+
}
|
|
388
|
+
}, focusRailHideDelayMs);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function clearFocusRailHideTimer() {
|
|
392
|
+
if (focusRailHideTimer !== null) {
|
|
393
|
+
window.clearTimeout(focusRailHideTimer);
|
|
394
|
+
focusRailHideTimer = null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function showFocusHintOnce() {
|
|
399
|
+
if (focusHintShownForSession || !root.classList.contains("is-focus-mode")) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
focusHintShownForSession = true;
|
|
404
|
+
focusHint?.classList.add("is-visible");
|
|
405
|
+
clearFocusHintHideTimer();
|
|
406
|
+
focusHintHideTimer = window.setTimeout(() => {
|
|
407
|
+
focusHint?.classList.remove("is-visible");
|
|
408
|
+
focusHintHideTimer = null;
|
|
409
|
+
}, focusHintHideDelayMs);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function hideFocusHint() {
|
|
413
|
+
clearFocusHintHideTimer();
|
|
414
|
+
focusHint?.classList.remove("is-visible");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function resetFocusHintSession() {
|
|
418
|
+
focusHintShownForSession = false;
|
|
419
|
+
hideFocusHint();
|
|
305
420
|
}
|
|
306
421
|
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
)
|
|
422
|
+
function clearFocusHintHideTimer() {
|
|
423
|
+
if (focusHintHideTimer !== null) {
|
|
424
|
+
window.clearTimeout(focusHintHideTimer);
|
|
425
|
+
focusHintHideTimer = null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function syncFullscreenForFocusMode(enabled) {
|
|
430
|
+
if (typeof fullscreenTarget.requestFullscreen !== "function" || typeof document.exitFullscreen !== "function") {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
if (enabled) {
|
|
436
|
+
if (!document.fullscreenElement) {
|
|
437
|
+
await fullscreenTarget.requestFullscreen();
|
|
438
|
+
}
|
|
439
|
+
} else if (document.fullscreenElement) {
|
|
440
|
+
await document.exitFullscreen();
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// Ignore browsers that block or do not support fullscreen transitions.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function handleFullscreenChange() {
|
|
448
|
+
root.classList.toggle("is-reader-fullscreen", Boolean(document.fullscreenElement));
|
|
449
|
+
|
|
450
|
+
if (root.classList.contains("is-focus-mode")) {
|
|
451
|
+
hideFocusHint();
|
|
452
|
+
showFocusRailTemporarily();
|
|
453
|
+
}
|
|
316
454
|
}
|
|
317
455
|
|
|
318
456
|
function updateMarkerUi(marker) {
|
|
@@ -328,10 +466,6 @@ const renderedParagraphs = await Promise.all(
|
|
|
328
466
|
document.querySelectorAll("[data-reader-marker-article]").forEach((article) => {
|
|
329
467
|
article.classList.toggle("has-voice-marker", activeScopeId !== null && article.dataset.readerMarkerScope === activeScopeId);
|
|
330
468
|
});
|
|
331
|
-
|
|
332
|
-
if (markerPlayButton instanceof HTMLButtonElement) {
|
|
333
|
-
markerPlayButton.disabled = !activeScopeId;
|
|
334
|
-
}
|
|
335
469
|
}
|
|
336
470
|
|
|
337
471
|
function updateActiveScene() {
|
package/src/styles/global.css
CHANGED
|
@@ -124,7 +124,6 @@ body.is-focus-mode {
|
|
|
124
124
|
body.is-focus-mode .masthead,
|
|
125
125
|
body.is-focus-mode .focus-hidden,
|
|
126
126
|
body.is-focus-mode .reader-overlay,
|
|
127
|
-
body.is-focus-mode .canon-overlay,
|
|
128
127
|
body.is-focus-mode .tts-player,
|
|
129
128
|
body.is-focus-mode .scene-tools,
|
|
130
129
|
body.is-focus-mode .scene-media {
|
|
@@ -144,6 +143,10 @@ body.is-focus-mode .chapter-reading-frame {
|
|
|
144
143
|
box-shadow: none;
|
|
145
144
|
}
|
|
146
145
|
|
|
146
|
+
body.is-focus-mode .chapter-reading-frame > .section {
|
|
147
|
+
margin-top: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
147
150
|
body.is-focus-mode .chapter-focus-banner {
|
|
148
151
|
display: block;
|
|
149
152
|
border: 0;
|
|
@@ -159,7 +162,11 @@ body.is-focus-mode .chapter-focus-banner {
|
|
|
159
162
|
body.is-focus-mode .scene {
|
|
160
163
|
max-width: min(100%, calc(var(--reader-measure) + 8ch));
|
|
161
164
|
margin-inline: auto;
|
|
162
|
-
padding:
|
|
165
|
+
padding: 1.5rem 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
body.is-focus-mode .scene-header {
|
|
169
|
+
display: none;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
body.is-focus-mode .scene-stack .scene::after {
|
|
@@ -174,6 +181,16 @@ body.is-focus-mode .focus-rail {
|
|
|
174
181
|
display: block;
|
|
175
182
|
}
|
|
176
183
|
|
|
184
|
+
body.is-focus-mode .focus-rail.is-hidden {
|
|
185
|
+
opacity: 0;
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
transform: translate(-50%, calc(100% + 1.5rem));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
body.is-focus-mode .focus-hint {
|
|
191
|
+
display: inline-flex;
|
|
192
|
+
}
|
|
193
|
+
|
|
177
194
|
body.tts-enabled .shell {
|
|
178
195
|
padding-bottom: 8rem;
|
|
179
196
|
}
|
|
@@ -308,6 +325,12 @@ body.tts-enabled .shell {
|
|
|
308
325
|
border-color: color-mix(in srgb, var(--accent) 25%, var(--line));
|
|
309
326
|
}
|
|
310
327
|
|
|
328
|
+
.masthead-btn[aria-pressed="true"] {
|
|
329
|
+
background: var(--accent-soft);
|
|
330
|
+
color: var(--text);
|
|
331
|
+
border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
|
|
332
|
+
}
|
|
333
|
+
|
|
311
334
|
/* Primary TTS play button */
|
|
312
335
|
.tts-button.is-primary {
|
|
313
336
|
background: var(--accent);
|
|
@@ -359,6 +382,18 @@ body.tts-enabled .shell {
|
|
|
359
382
|
border-color: color-mix(in srgb, var(--accent) 22%, var(--line));
|
|
360
383
|
}
|
|
361
384
|
|
|
385
|
+
.sr-only {
|
|
386
|
+
position: absolute;
|
|
387
|
+
width: 1px;
|
|
388
|
+
height: 1px;
|
|
389
|
+
padding: 0;
|
|
390
|
+
margin: -1px;
|
|
391
|
+
overflow: hidden;
|
|
392
|
+
clip: rect(0, 0, 0, 0);
|
|
393
|
+
white-space: nowrap;
|
|
394
|
+
border: 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
362
397
|
.chip-row {
|
|
363
398
|
display: flex;
|
|
364
399
|
flex-wrap: wrap;
|
|
@@ -1126,6 +1161,12 @@ mark {
|
|
|
1126
1161
|
gap: 0.75rem;
|
|
1127
1162
|
}
|
|
1128
1163
|
|
|
1164
|
+
.tts-player__top-actions {
|
|
1165
|
+
display: inline-flex;
|
|
1166
|
+
align-items: center;
|
|
1167
|
+
gap: 0.5rem;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1129
1170
|
.tts-player__title {
|
|
1130
1171
|
margin: 0.15rem 0 0;
|
|
1131
1172
|
font-weight: 600;
|
|
@@ -1139,7 +1180,7 @@ mark {
|
|
|
1139
1180
|
position: fixed;
|
|
1140
1181
|
left: 50%;
|
|
1141
1182
|
bottom: 1rem;
|
|
1142
|
-
transform:
|
|
1183
|
+
transform: translate(-50%, 0);
|
|
1143
1184
|
z-index: 46;
|
|
1144
1185
|
width: min(760px, calc(100vw - 1rem));
|
|
1145
1186
|
padding: 1rem 1.25rem;
|
|
@@ -1147,6 +1188,8 @@ mark {
|
|
|
1147
1188
|
border: 1px solid var(--line);
|
|
1148
1189
|
border-radius: 16px;
|
|
1149
1190
|
box-shadow: var(--shadow);
|
|
1191
|
+
opacity: 1;
|
|
1192
|
+
transition: opacity 180ms ease, transform 180ms ease;
|
|
1150
1193
|
}
|
|
1151
1194
|
|
|
1152
1195
|
.focus-rail__meta {
|
|
@@ -1163,6 +1206,54 @@ mark {
|
|
|
1163
1206
|
color: var(--text);
|
|
1164
1207
|
}
|
|
1165
1208
|
|
|
1209
|
+
.focus-rail__jump {
|
|
1210
|
+
min-width: min(320px, 100%);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.focus-rail__jump select {
|
|
1214
|
+
min-width: min(320px, 100%);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
.focus-hint {
|
|
1218
|
+
display: none;
|
|
1219
|
+
position: fixed;
|
|
1220
|
+
left: 50%;
|
|
1221
|
+
bottom: 6.25rem;
|
|
1222
|
+
transform: translate(-50%, 0.8rem);
|
|
1223
|
+
z-index: 45;
|
|
1224
|
+
max-width: min(520px, calc(100vw - 2rem));
|
|
1225
|
+
padding: 0.7rem 1rem;
|
|
1226
|
+
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line));
|
|
1227
|
+
border-radius: 999px;
|
|
1228
|
+
background: color-mix(in srgb, var(--surface) 92%, var(--app-bg));
|
|
1229
|
+
color: var(--text-muted);
|
|
1230
|
+
font-size: 0.82rem;
|
|
1231
|
+
line-height: 1.45;
|
|
1232
|
+
text-align: center;
|
|
1233
|
+
box-shadow: var(--shadow);
|
|
1234
|
+
opacity: 0;
|
|
1235
|
+
pointer-events: none;
|
|
1236
|
+
transition: opacity 220ms ease, transform 220ms ease;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
.focus-hint.is-visible {
|
|
1240
|
+
opacity: 1;
|
|
1241
|
+
transform: translate(-50%, 0);
|
|
1242
|
+
animation: focus-hint-pulse 1.8s ease-in-out 2;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
@keyframes focus-hint-pulse {
|
|
1246
|
+
0% {
|
|
1247
|
+
box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.16);
|
|
1248
|
+
}
|
|
1249
|
+
70% {
|
|
1250
|
+
box-shadow: 0 0 0 10px rgba(139, 92, 246, 0);
|
|
1251
|
+
}
|
|
1252
|
+
100% {
|
|
1253
|
+
box-shadow: var(--shadow);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1166
1257
|
/* ─── Related links section ──────────────────────────────────── */
|
|
1167
1258
|
.scene > h2 {
|
|
1168
1259
|
margin-bottom: 0.9rem;
|
|
@@ -1343,6 +1434,22 @@ mark {
|
|
|
1343
1434
|
transform: none;
|
|
1344
1435
|
}
|
|
1345
1436
|
|
|
1437
|
+
.focus-hint {
|
|
1438
|
+
left: 0.75rem;
|
|
1439
|
+
right: 0.75rem;
|
|
1440
|
+
bottom: 6.75rem;
|
|
1441
|
+
max-width: none;
|
|
1442
|
+
transform: translateY(0.8rem);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
.focus-hint.is-visible {
|
|
1446
|
+
transform: translateY(0);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
body.is-focus-mode .focus-rail.is-hidden {
|
|
1450
|
+
transform: translateY(calc(100% + 1.5rem));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1346
1453
|
body.is-focus-mode .shell {
|
|
1347
1454
|
width: calc(100vw - 1rem);
|
|
1348
1455
|
padding-bottom: 13rem;
|