feat(post): Vorlesen-Button (Web Speech API), nur wenn unterstützt
PostActions: 🔊 Vorlesen liest Artikeltext (Titel+Body, versteckte Elemente gefiltert), Toggle Pause/Weiter + Stop, deutsche Stimme, satzweise, stoppt bei Seitenwechsel. Button erscheint nur wenn speechSynthesis verfügbar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
@@ -156,6 +156,73 @@
|
||||
function print() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ── Vorlesen (Web Speech API) ───────────────────────────────────────────
|
||||
// Button erscheint nur, wenn der Browser Sprachausgabe unterstützt.
|
||||
let canSpeak = $state(false);
|
||||
let speaking = $state(false);
|
||||
let paused = $state(false);
|
||||
$effect(() => {
|
||||
canSpeak = typeof window !== "undefined" && "speechSynthesis" in window;
|
||||
});
|
||||
|
||||
function getArticleText(): string {
|
||||
const art = document.querySelector("article");
|
||||
if (!art) return "";
|
||||
const clone = art.cloneNode(true) as HTMLElement;
|
||||
clone
|
||||
.querySelectorAll("[hidden], .sr-only, script, style, nav, figure figcaption")
|
||||
.forEach((n) => n.remove());
|
||||
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function speak() {
|
||||
const synth = window.speechSynthesis;
|
||||
synth.cancel();
|
||||
const text = getArticleText();
|
||||
if (!text) return;
|
||||
// In Sätze zerlegen — manche Engines schneiden lange Utterances ab.
|
||||
const chunks = text.match(/[^.!?]+[.!?]*(?:\s|$)/g) ?? [text];
|
||||
const voice =
|
||||
synth.getVoices().find((v) => v.lang?.toLowerCase().startsWith("de")) ?? null;
|
||||
chunks.forEach((c, i) => {
|
||||
const u = new SpeechSynthesisUtterance(c.trim());
|
||||
u.lang = "de-DE";
|
||||
if (voice) u.voice = voice;
|
||||
if (i === chunks.length - 1)
|
||||
u.onend = () => {
|
||||
speaking = false;
|
||||
paused = false;
|
||||
};
|
||||
synth.speak(u);
|
||||
});
|
||||
speaking = true;
|
||||
paused = false;
|
||||
}
|
||||
|
||||
function toggleSpeak() {
|
||||
const synth = window.speechSynthesis;
|
||||
if (!speaking) {
|
||||
speak();
|
||||
} else if (paused) {
|
||||
synth.resume();
|
||||
paused = false;
|
||||
} else {
|
||||
synth.pause();
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopSpeak() {
|
||||
window.speechSynthesis.cancel();
|
||||
speaking = false;
|
||||
paused = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window)
|
||||
window.speechSynthesis.cancel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -194,6 +261,28 @@
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
{#if canSpeak}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSpeak}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors {speaking && !paused ? 'text-wald-700!' : ''}"
|
||||
aria-label={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
|
||||
title={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
|
||||
>
|
||||
<Icon icon={!speaking ? "lucide:volume-2" : paused ? "lucide:play" : "lucide:pause"} class="size-3.5" />
|
||||
</button>
|
||||
{#if speaking}
|
||||
<button
|
||||
type="button"
|
||||
onclick={stopSpeak}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label="Vorlesen stoppen"
|
||||
title="Vorlesen stoppen"
|
||||
>
|
||||
<Icon icon="lucide:square" class="size-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyLink}
|
||||
|
||||
@@ -69,9 +69,18 @@
|
||||
"menu": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 5h16M4 12h16M4 19h16\"/>"
|
||||
},
|
||||
"heart-handshake": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676a.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052a2.12 2.12 0 0 0-.004-3a2.124 2.124 0 1 0 3-3a2.124 2.124 0 0 0 3.004 0a2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0a2 2 0 0 1 0-2.828l2.823-2.762\"/>"
|
||||
},
|
||||
"calendar-plus": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 19h6M16 2v4m3 10v6m2-9.402V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5M3 10h18M8 2v4\"/>"
|
||||
},
|
||||
"message-circle": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092a10 10 0 1 0-4.777-4.719\"/>"
|
||||
},
|
||||
"square": {
|
||||
"body": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" rx=\"2\"/>"
|
||||
},
|
||||
"printer": {
|
||||
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6\"/><rect width=\"12\" height=\"8\" x=\"6\" y=\"14\" rx=\"1\"/></g>"
|
||||
},
|
||||
@@ -132,9 +141,6 @@
|
||||
"plus": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14m-7-7v14\"/>"
|
||||
},
|
||||
"calendar-plus": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 19h6M16 2v4m3 10v6m2-9.402V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5M3 10h18M8 2v4\"/>"
|
||||
},
|
||||
"youtube": {
|
||||
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M2.5 17a24.1 24.1 0 0 1 0-10a2 2 0 0 1 1.4-1.4a49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10a2 2 0 0 1-1.4 1.4a49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17\"/><path d=\"m10 15l5-3l-5-3z\"/></g>",
|
||||
"hidden": true
|
||||
|
||||
Reference in New Issue
Block a user