narrarium-astro-reader 0.1.21 → 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 +3 -0
- package/cli-dist/lib/canon-mentions.d.ts +8 -0
- package/cli-dist/lib/canon-mentions.d.ts.map +1 -0
- package/cli-dist/lib/canon-mentions.js +86 -0
- package/cli-dist/lib/canon-mentions.js.map +1 -0
- package/package.json +2 -2
- package/src/components/ReaderRuntime.astro +125 -17
- package/src/layouts/BaseLayout.astro +1 -0
- package/src/lib/canon-mentions.ts +105 -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/`
|
|
@@ -43,6 +45,7 @@ In public mode:
|
|
|
43
45
|
- secrets stay hidden from the public atlas and nav
|
|
44
46
|
- direct canon pages fall back to teaser or locked views when `known_from` or `reveal_in` say a dossier is not safe yet
|
|
45
47
|
- search, canon popups, and backlinks follow the same thresholds
|
|
48
|
+
- chapter and scene prose should keep canon names as plain text; the reader upgrades visible mentions into canon popups and also rewrites legacy internal canon links at runtime
|
|
46
49
|
|
|
47
50
|
If you want an author-only or spoiler-friendly deployment, enable full canon mode:
|
|
48
51
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type CanonMentionEntry = {
|
|
2
|
+
id: string;
|
|
3
|
+
href: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function buildCanonHrefIndex(entries: CanonMentionEntry[]): Map<string, string>;
|
|
6
|
+
export declare function resolveCanonEntryIdFromHref(href: string, entries: CanonMentionEntry[]): string | null;
|
|
7
|
+
export declare function normalizeCanonEntityHref(href: string): string | null;
|
|
8
|
+
//# sourceMappingURL=canon-mentions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canon-mentions.d.ts","sourceRoot":"","sources":["../../src/lib/canon-mentions.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAarF;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,GAAG,MAAM,GAAG,IAAI,CAOrG;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyCpE"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export function buildCanonHrefIndex(entries) {
|
|
2
|
+
const index = new Map();
|
|
3
|
+
for (const entry of entries) {
|
|
4
|
+
const normalizedHref = normalizeCanonEntityHref(entry.href);
|
|
5
|
+
if (!normalizedHref) {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
index.set(normalizedHref, entry.id);
|
|
9
|
+
}
|
|
10
|
+
return index;
|
|
11
|
+
}
|
|
12
|
+
export function resolveCanonEntryIdFromHref(href, entries) {
|
|
13
|
+
const normalizedHref = normalizeCanonEntityHref(href);
|
|
14
|
+
if (!normalizedHref) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return buildCanonHrefIndex(entries).get(normalizedHref) ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function normalizeCanonEntityHref(href) {
|
|
20
|
+
const strippedOrigin = href
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^[a-z]+:\/\/[^/]+/i, "")
|
|
23
|
+
.split(/[?#]/, 1)[0]
|
|
24
|
+
.replace(/\\/g, "/");
|
|
25
|
+
if (!strippedOrigin) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const segments = strippedOrigin
|
|
29
|
+
.split("/")
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.filter((segment) => segment !== "." && segment !== "..");
|
|
32
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
33
|
+
const current = segments[index]?.toLowerCase();
|
|
34
|
+
if (!current) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (current === "timelines" && segments[index + 1]?.toLowerCase() === "events") {
|
|
38
|
+
const slug = normalizeSlugSegment(segments[index + 2]);
|
|
39
|
+
if (slug) {
|
|
40
|
+
return `timeline/${slug}/`;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const section = normalizeCanonSection(current);
|
|
45
|
+
if (!section) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const slug = normalizeSlugSegment(segments[index + 1]);
|
|
49
|
+
if (slug) {
|
|
50
|
+
return `${section}/${slug}/`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function normalizeCanonSection(segment) {
|
|
56
|
+
switch (segment.toLowerCase()) {
|
|
57
|
+
case "character":
|
|
58
|
+
case "characters":
|
|
59
|
+
return "characters";
|
|
60
|
+
case "location":
|
|
61
|
+
case "locations":
|
|
62
|
+
return "locations";
|
|
63
|
+
case "faction":
|
|
64
|
+
case "factions":
|
|
65
|
+
return "factions";
|
|
66
|
+
case "item":
|
|
67
|
+
case "items":
|
|
68
|
+
return "items";
|
|
69
|
+
case "secret":
|
|
70
|
+
case "secrets":
|
|
71
|
+
return "secrets";
|
|
72
|
+
case "timeline":
|
|
73
|
+
case "timeline-event":
|
|
74
|
+
return "timeline";
|
|
75
|
+
default:
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function normalizeSlugSegment(segment) {
|
|
80
|
+
if (!segment) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const normalized = segment.replace(/\.md$/i, "").trim().toLowerCase();
|
|
84
|
+
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=canon-mentions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canon-mentions.js","sourceRoot":"","sources":["../../src/lib/canon-mentions.ts"],"names":[],"mappings":"AAKA,MAAM,UAAU,mBAAmB,CAAC,OAA4B;IAC9D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IAExC,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,cAAc,GAAG,wBAAwB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5D,IAAI,CAAC,cAAc,EAAE,CAAC;YACpB,SAAS;QACX,CAAC;QAED,KAAK,CAAC,GAAG,CAAC,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;IACtC,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,IAAY,EAAE,OAA4B;IACpF,MAAM,cAAc,GAAG,wBAAwB,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,mBAAmB,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,IAAI,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY;IACnD,MAAM,cAAc,GAAG,IAAI;SACxB,IAAI,EAAE;SACN,OAAO,CAAC,oBAAoB,EAAE,EAAE,CAAC;SACjC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IACvB,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,QAAQ,GAAG,cAAc;SAC5B,KAAK,CAAC,GAAG,CAAC;SACV,MAAM,CAAC,OAAO,CAAC;SACf,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,GAAG,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC;IAE5D,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACxD,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;QAC/C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,SAAS;QACX,CAAC;QAED,IAAI,OAAO,KAAK,WAAW,IAAI,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,CAAC;YAC/E,MAAM,IAAI,GAAG,oBAAoB,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;YACvD,IAAI,IAAI,EAAE,CAAC;gBACT,OAAO,YAAY,IAAI,GAAG,CAAC;YAC7B,CAAC;YACD,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;QAC/C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,SAAS;QACX,CAAC;QAED,MAAM,IAAI,GAAG,oBAAoB,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;QACvD,IAAI,IAAI,EAAE,CAAC;YACT,OAAO,GAAG,OAAO,IAAI,IAAI,GAAG,CAAC;QAC/B,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,qBAAqB,CAAC,OAAe;IAC5C,QAAQ,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC;QAC9B,KAAK,WAAW,CAAC;QACjB,KAAK,YAAY;YACf,OAAO,YAAY,CAAC;QACtB,KAAK,UAAU,CAAC;QAChB,KAAK,WAAW;YACd,OAAO,WAAW,CAAC;QACrB,KAAK,SAAS,CAAC;QACf,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,MAAM,CAAC;QACZ,KAAK,OAAO;YACV,OAAO,OAAO,CAAC;QACjB,KAAK,QAAQ,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;QACnB,KAAK,UAAU,CAAC;QAChB,KAAK,gBAAgB;YACnB,OAAO,UAAU,CAAC;QACpB;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,OAA2B;IACvD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACtE,OAAO,cAAc,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC"}
|
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">
|
|
@@ -144,7 +147,9 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
144
147
|
<script type="application/json" id="canon-glossary-data" set:html={glossaryJson}></script>
|
|
145
148
|
<script type="application/json" id="reader-chapter-data" set:html={chaptersJson}></script>
|
|
146
149
|
|
|
147
|
-
<script
|
|
150
|
+
<script>
|
|
151
|
+
import { buildCanonHrefIndex, normalizeCanonEntityHref } from "../lib/canon-mentions.js";
|
|
152
|
+
|
|
148
153
|
const themeStorageKey = "narrarium-reader-theme";
|
|
149
154
|
const canonModeStorageKey = "narrarium-reader-canon";
|
|
150
155
|
const progressStorageKey = "narrarium-reader-progress";
|
|
@@ -154,6 +159,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
154
159
|
const ttsVoiceStorageKey = "narrarium-reader-tts-voice";
|
|
155
160
|
const ttsRateStorageKey = "narrarium-reader-tts-rate";
|
|
156
161
|
const ttsProgressStorageKey = "narrarium-reader-tts-progress";
|
|
162
|
+
const ttsPlayerDismissedStorageKey = "narrarium-reader-tts-player-dismissed";
|
|
157
163
|
const glossaryDataElement = document.getElementById("canon-glossary-data");
|
|
158
164
|
const chapterDataElement = document.getElementById("reader-chapter-data");
|
|
159
165
|
const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
|
|
@@ -354,6 +360,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
354
360
|
const limit = getSelectedSpoilerLimit();
|
|
355
361
|
const visibleEntries = glossaryEntries.filter((entry) => isEntryVisible(entry, limit));
|
|
356
362
|
const glossaryById = new Map(visibleEntries.map((entry) => [entry.id, entry]));
|
|
363
|
+
const hrefIndex = buildCanonHrefIndex(visibleEntries);
|
|
357
364
|
const termMap = new Map();
|
|
358
365
|
|
|
359
366
|
for (const entry of visibleEntries) {
|
|
@@ -368,15 +375,15 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
const terms = [...termMap.keys()].sort((left, right) => right.length - left.length);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
const pattern = terms.length > 0
|
|
379
|
+
? new RegExp(`(^|[^\\p{L}\\p{N}])(${terms.map(escapeRegex).join("|")})(?=$|[^\\p{L}\\p{N}])`, "giu")
|
|
380
|
+
: null;
|
|
374
381
|
|
|
375
|
-
enhanceCanonRoot(document, pattern, termMap);
|
|
376
|
-
initializeCanonOverlay(pattern, termMap, glossaryById, limit);
|
|
382
|
+
enhanceCanonRoot(document, pattern, termMap, hrefIndex);
|
|
383
|
+
initializeCanonOverlay(pattern, termMap, hrefIndex, glossaryById, limit);
|
|
377
384
|
}
|
|
378
385
|
|
|
379
|
-
function enhanceCanonRoot(root, pattern, termMap) {
|
|
386
|
+
function enhanceCanonRoot(root, pattern, termMap, hrefIndex) {
|
|
380
387
|
const containers = root instanceof Document
|
|
381
388
|
? root.querySelectorAll(".prose p, .prose li, .prose blockquote")
|
|
382
389
|
: root.matches?.(".prose")
|
|
@@ -385,7 +392,11 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
385
392
|
|
|
386
393
|
containers.forEach((container) => {
|
|
387
394
|
if (container.dataset.canonEnhanced === "true") return;
|
|
395
|
+
rewriteLegacyCanonLinks(container, hrefIndex);
|
|
388
396
|
container.dataset.canonEnhanced = "true";
|
|
397
|
+
if (!pattern) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
389
400
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
390
401
|
const textNodes = [];
|
|
391
402
|
while (walker.nextNode()) {
|
|
@@ -430,12 +441,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
430
441
|
}
|
|
431
442
|
|
|
432
443
|
if (entryId) {
|
|
433
|
-
|
|
434
|
-
button.type = "button";
|
|
435
|
-
button.className = "canon-mention";
|
|
436
|
-
button.dataset.canonId = entryId;
|
|
437
|
-
button.textContent = term;
|
|
438
|
-
fragment.append(button);
|
|
444
|
+
fragment.append(createCanonMentionButton(entryId, term));
|
|
439
445
|
} else {
|
|
440
446
|
fragment.append(term);
|
|
441
447
|
}
|
|
@@ -450,7 +456,33 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
450
456
|
textNode.replaceWith(fragment);
|
|
451
457
|
}
|
|
452
458
|
|
|
453
|
-
function
|
|
459
|
+
function rewriteLegacyCanonLinks(container, hrefIndex) {
|
|
460
|
+
container.querySelectorAll("a[href]").forEach((anchor) => {
|
|
461
|
+
if (!(anchor instanceof HTMLAnchorElement) || anchor.closest("[data-no-canon]")) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const normalizedHref = normalizeCanonEntityHref(anchor.getAttribute("href") || "");
|
|
466
|
+
if (!normalizedHref) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const label = anchor.textContent || "";
|
|
471
|
+
const entryId = hrefIndex.get(normalizedHref);
|
|
472
|
+
anchor.replaceWith(entryId ? createCanonMentionButton(entryId, label) : document.createTextNode(label));
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function createCanonMentionButton(entryId, label) {
|
|
477
|
+
const button = document.createElement("button");
|
|
478
|
+
button.type = "button";
|
|
479
|
+
button.className = "canon-mention";
|
|
480
|
+
button.dataset.canonId = entryId;
|
|
481
|
+
button.textContent = label;
|
|
482
|
+
return button;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function initializeCanonOverlay(pattern, termMap, hrefIndex, glossaryById, limit) {
|
|
454
486
|
const overlay = document.querySelector("[data-canon-overlay]");
|
|
455
487
|
if (!overlay || overlay.dataset.bound === "true") return;
|
|
456
488
|
overlay.dataset.bound = "true";
|
|
@@ -482,7 +514,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
482
514
|
link.href = entry.href;
|
|
483
515
|
link.hidden = !revealed;
|
|
484
516
|
body.innerHTML = revealed ? entry.bodyHtml || "" : "";
|
|
485
|
-
enhanceCanonRoot(body, pattern, termMap);
|
|
517
|
+
enhanceCanonRoot(body, pattern, termMap, hrefIndex);
|
|
486
518
|
metadata.replaceChildren(...(entry.metadataEntries || []).map((item) => {
|
|
487
519
|
const row = document.createElement("div");
|
|
488
520
|
row.className = "meta-row";
|
|
@@ -629,12 +661,12 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
629
661
|
const player = document.querySelector("[data-tts-player]");
|
|
630
662
|
const roots = [...document.querySelectorAll("[data-tts-root]")];
|
|
631
663
|
const triggers = [...document.querySelectorAll("[data-tts-trigger]")];
|
|
664
|
+
const panelToggle = document.querySelector("[data-tts-panel-toggle]");
|
|
632
665
|
if (!(player instanceof HTMLElement) || roots.length === 0) {
|
|
633
666
|
return;
|
|
634
667
|
}
|
|
635
668
|
|
|
636
669
|
document.body.classList.add("tts-enabled");
|
|
637
|
-
player.hidden = false;
|
|
638
670
|
triggers.forEach((trigger) => {
|
|
639
671
|
if (trigger instanceof HTMLElement) {
|
|
640
672
|
trigger.hidden = false;
|
|
@@ -646,12 +678,17 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
646
678
|
return;
|
|
647
679
|
}
|
|
648
680
|
|
|
681
|
+
if (panelToggle instanceof HTMLButtonElement) {
|
|
682
|
+
panelToggle.hidden = false;
|
|
683
|
+
}
|
|
684
|
+
|
|
649
685
|
const titleElement = player.querySelector("[data-tts-title]");
|
|
650
686
|
const labelElement = player.querySelector("[data-tts-label]");
|
|
651
687
|
const badgeElement = player.querySelector("[data-tts-badge]");
|
|
652
688
|
const statusElement = player.querySelector("[data-tts-status]");
|
|
653
689
|
const playToggle = player.querySelector("[data-tts-play-toggle]");
|
|
654
690
|
const stopButton = player.querySelector("[data-tts-stop]");
|
|
691
|
+
const closeButton = player.querySelector("[data-tts-close]");
|
|
655
692
|
const voiceSelect = player.querySelector("[data-tts-voice]");
|
|
656
693
|
const rateInput = player.querySelector("[data-tts-rate]");
|
|
657
694
|
|
|
@@ -662,6 +699,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
662
699
|
!(statusElement instanceof HTMLElement) ||
|
|
663
700
|
!(playToggle instanceof HTMLButtonElement) ||
|
|
664
701
|
!(stopButton instanceof HTMLButtonElement) ||
|
|
702
|
+
!(closeButton instanceof HTMLButtonElement) ||
|
|
665
703
|
!(voiceSelect instanceof HTMLSelectElement) ||
|
|
666
704
|
!(rateInput instanceof HTMLInputElement)
|
|
667
705
|
) {
|
|
@@ -681,14 +719,19 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
681
719
|
runId: 0,
|
|
682
720
|
speaking: false,
|
|
683
721
|
paused: false,
|
|
722
|
+
focusMode: document.body.classList.contains("is-focus-mode"),
|
|
723
|
+
dismissed: readStorage(ttsPlayerDismissedStorageKey) === "true",
|
|
684
724
|
titleElement,
|
|
685
725
|
labelElement,
|
|
686
726
|
badgeElement,
|
|
687
727
|
statusElement,
|
|
688
728
|
playToggle,
|
|
689
729
|
stopButton,
|
|
730
|
+
closeButton,
|
|
690
731
|
voiceSelect,
|
|
691
732
|
rateInput,
|
|
733
|
+
player,
|
|
734
|
+
panelToggle,
|
|
692
735
|
};
|
|
693
736
|
|
|
694
737
|
const savedProgress = readJson(ttsProgressStorageKey);
|
|
@@ -711,10 +754,24 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
711
754
|
window.speechSynthesis.onvoiceschanged = () => populateVoiceOptions(state);
|
|
712
755
|
}
|
|
713
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
|
+
|
|
714
769
|
window.addEventListener("beforeunload", () => {
|
|
715
770
|
state.runId += 1;
|
|
716
771
|
state.speech.cancel();
|
|
717
772
|
});
|
|
773
|
+
|
|
774
|
+
hideTtsPlayer(state, { persistDismissed: false });
|
|
718
775
|
}
|
|
719
776
|
|
|
720
777
|
function buildTtsScopes(roots) {
|
|
@@ -855,6 +912,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
855
912
|
|
|
856
913
|
trigger.dataset.defaultLabel = trigger.textContent || "Read aloud";
|
|
857
914
|
trigger.addEventListener("click", () => {
|
|
915
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
858
916
|
const scopeId = trigger.dataset.ttsTrigger || "chapter";
|
|
859
917
|
startTtsScope(state, scopeId);
|
|
860
918
|
updateTtsTriggerButtons(triggers, state);
|
|
@@ -864,6 +922,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
864
922
|
|
|
865
923
|
function bindTtsPlayerControls(state) {
|
|
866
924
|
state.playToggle.addEventListener("click", () => {
|
|
925
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
867
926
|
if (state.speech.speaking && !state.speech.paused) {
|
|
868
927
|
state.speech.pause();
|
|
869
928
|
state.paused = true;
|
|
@@ -886,6 +945,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
886
945
|
updateTtsUi(state);
|
|
887
946
|
});
|
|
888
947
|
|
|
948
|
+
state.closeButton.addEventListener("click", () => {
|
|
949
|
+
hideTtsPlayer(state, { persistDismissed: true });
|
|
950
|
+
});
|
|
951
|
+
|
|
889
952
|
state.voiceSelect.addEventListener("change", () => {
|
|
890
953
|
state.voiceUri = state.voiceSelect.value;
|
|
891
954
|
writeStorage(ttsVoiceStorageKey, state.voiceUri);
|
|
@@ -909,6 +972,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
909
972
|
window.addEventListener("narrarium:tts-start", (event) => {
|
|
910
973
|
const detail = event instanceof CustomEvent ? event.detail || {} : {};
|
|
911
974
|
const scopeId = typeof detail.scopeId === "string" ? detail.scopeId : "chapter";
|
|
975
|
+
showTtsPlayer(state, { persistDismissed: false });
|
|
912
976
|
startTtsScope(state, scopeId, { fromStart: Boolean(detail.fromStart) });
|
|
913
977
|
updateTtsTriggerButtons(state.triggers, state);
|
|
914
978
|
});
|
|
@@ -918,6 +982,15 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
918
982
|
stopTts(state, detail.keepProgress !== false);
|
|
919
983
|
updateTtsUi(state);
|
|
920
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
|
+
});
|
|
921
994
|
}
|
|
922
995
|
|
|
923
996
|
function populateVoiceOptions(state) {
|
|
@@ -1128,6 +1201,41 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
1128
1201
|
updateTtsTriggerButtons(state.triggers, state);
|
|
1129
1202
|
}
|
|
1130
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
|
+
|
|
1131
1239
|
function highlightTtsSegment(segment) {
|
|
1132
1240
|
clearTtsHighlight();
|
|
1133
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>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type CanonMentionEntry = {
|
|
2
|
+
id: string;
|
|
3
|
+
href: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function buildCanonHrefIndex(entries: CanonMentionEntry[]): Map<string, string> {
|
|
7
|
+
const index = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const normalizedHref = normalizeCanonEntityHref(entry.href);
|
|
11
|
+
if (!normalizedHref) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
index.set(normalizedHref, entry.id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveCanonEntryIdFromHref(href: string, entries: CanonMentionEntry[]): string | null {
|
|
22
|
+
const normalizedHref = normalizeCanonEntityHref(href);
|
|
23
|
+
if (!normalizedHref) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return buildCanonHrefIndex(entries).get(normalizedHref) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeCanonEntityHref(href: string): string | null {
|
|
31
|
+
const strippedOrigin = href
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/^[a-z]+:\/\/[^/]+/i, "")
|
|
34
|
+
.split(/[?#]/, 1)[0]
|
|
35
|
+
.replace(/\\/g, "/");
|
|
36
|
+
if (!strippedOrigin) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const segments = strippedOrigin
|
|
41
|
+
.split("/")
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.filter((segment) => segment !== "." && segment !== "..");
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
46
|
+
const current = segments[index]?.toLowerCase();
|
|
47
|
+
if (!current) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (current === "timelines" && segments[index + 1]?.toLowerCase() === "events") {
|
|
52
|
+
const slug = normalizeSlugSegment(segments[index + 2]);
|
|
53
|
+
if (slug) {
|
|
54
|
+
return `timeline/${slug}/`;
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const section = normalizeCanonSection(current);
|
|
60
|
+
if (!section) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const slug = normalizeSlugSegment(segments[index + 1]);
|
|
65
|
+
if (slug) {
|
|
66
|
+
return `${section}/${slug}/`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCanonSection(segment: string): string | null {
|
|
74
|
+
switch (segment.toLowerCase()) {
|
|
75
|
+
case "character":
|
|
76
|
+
case "characters":
|
|
77
|
+
return "characters";
|
|
78
|
+
case "location":
|
|
79
|
+
case "locations":
|
|
80
|
+
return "locations";
|
|
81
|
+
case "faction":
|
|
82
|
+
case "factions":
|
|
83
|
+
return "factions";
|
|
84
|
+
case "item":
|
|
85
|
+
case "items":
|
|
86
|
+
return "items";
|
|
87
|
+
case "secret":
|
|
88
|
+
case "secrets":
|
|
89
|
+
return "secrets";
|
|
90
|
+
case "timeline":
|
|
91
|
+
case "timeline-event":
|
|
92
|
+
return "timeline";
|
|
93
|
+
default:
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeSlugSegment(segment: string | undefined): string | null {
|
|
99
|
+
if (!segment) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalized = segment.replace(/\.md$/i, "").trim().toLowerCase();
|
|
104
|
+
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
|
105
|
+
}
|
|
@@ -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;
|