Fix casting-feed freeze, zip cast export, character generation prompts (v1.12.90)
The per-word <span> wrapping behind "click any word to assign" created ~100k DOM nodes at book scale and froze the tab on every feed redraw; replaced with native caretRangeFromPoint word detection plus a single reused hover overlay — same UX, zero extra DOM. Export button gained a 2s re-entry guard (queued clicks during a freeze fired as a download burst) and now delivers one zip: the cast script in Markdown plus a sheet per character. Character detail view gains a Generation Prompts section — four fold-out copy boxes (Voice Design, Character Image, SillyTavern card, Concept Art sheet) filled by one LLM call over the full profile via the new /api/character-generate-prompts endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
637aeb3a2c
commit
e02ca4d703
45
CHANGELOG.md
45
CHANGELOG.md
@ -9,6 +9,51 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.12.90] — 2026-07-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Page freezes ("Page Unresponsive") after opening a book / the casting feed** — the recent "click any word to assign" feature wrapped every single word of every casting segment in its own `<span>` with hover styles; at book scale (~1,500 segments) that meant ~100,000 extra DOM nodes rebuilt synchronously on every feed redraw, freezing the tab. The spans are gone: the word under the cursor is now found via the browser's native caret-position API (`caretRangeFromPoint`) and highlighted with a single reused overlay element — same click/hover/drag-to-assign behaviour, zero extra DOM.
|
||||||
|
- **Burst of identical export downloads** — clicks queued up while the page was frozen could all fire at once on the export button when the tab unblocked, spawning one download + save-dialog per queued click. The export now ignores re-triggers for 2 seconds (and the freeze itself is fixed above).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **"Export cast .md" is now "Export cast .zip"** — one zip bundle containing the cast as a readable Markdown script plus a Markdown sheet per character of the book (identity, appearance, personality, story, abilities, and the generation prompts), instead of a single cast file and no character sheets at all.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Generation Prompts on every character sheet** — a new section in the Character Library detail view with four fold-out, copy-ready prompt boxes: Voice Design (Qwen3 TTS), Character Image (profile portrait), SillyTavern character card, and Concept Art (turnaround/model sheet). A "Generate" button fills all four in one LLM call over the character's complete profile — the previous behaviour generated voice/image prompts passage-by-passage during sheet extraction, where the model only ever saw a fraction of the character. The boxes are editable in place (autosaved like every other sheet field) and the results are included in the cast .zip export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.12.89] — 2026-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Quick "add alias" shortcut in the Casting sidebar** — hovering a character in the "Characters found" list now reveals a small tag icon; clicking it opens a tiny popup to add an "also known as" name (e.g. "Garthai" for "Sharraz Garthai") without leaving the casting screen. Writes through the same `clUpsert` the rest of the app uses, so the alias is immediately shared with Rehearser/Character sheets and starts getting recognized/underlined in the casting text right away.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.12.88] — 2026-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pipeline stepper prev/next navigation** — small chevron buttons flank the stepper to step to the nearest reachable stage in either direction, instead of only being able to jump directly to a specific stop.
|
||||||
|
- **Footer engine chips are now clickable fly-up menus** — click the LLM/STT/TTS chip in the footer status bar to quickly switch the active model (LLM: fetches the live model list for the current endpoint) or backend (STT/TTS: applies your pick to every matching picker across the app) without hunting through Settings or each screen's own dropdown.
|
||||||
|
- **Click or drag a name inside the casting text to assign it** — every word in the narration/dialogue text is now hoverable and clickable, not just the speaker label. Clicking a word opens the assign popup pre-filled with it; dragging across several words (for a multi-word name the roster doesn't know yet, e.g. "Sharraz Garthai") pre-fills the full phrase; double-clicking an already-known name/alias assigns it immediately with no popup. Built on the existing text-selection infrastructure (the "select text to split this segment" feature) rather than a separate mechanism, so the two don't fight over the same drag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.12.87] — 2026-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Real paragraph/chapter-break detection for PDFs** — extraction now flags where a new paragraph starts (`readerMarkParagraphBreaks`) by comparing each line's vertical gap against the page's typical line spacing; a heading/image band followed by a large gap before body text is caught by the same check. The break is preserved as a real blank line all the way through sentence-building, unit-grouping, `audiobookScopeText`, and `splitTextIntoChunks` — previously every paragraph and chapter heading in a book was silently joined into one run-on blob before the casting LLM ever saw the text. The attribution prompts (default, saved-prompt auto-upgrade, "2nd Quality Run", and the server-side fallback) now explain how to read the blank lines, including treating a short standalone line before one as a chapter heading rather than dialogue.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Merging two casting segments dropped their page number** — `_abMergedSegment` built a fresh segment object and never carried over `.page` from either side, so a merged row would silently render as if it belonged to whatever page card came before it. It now anchors to the earlier segment's page.
|
||||||
|
- **Export cast .md was a JSON dump with a Markdown label** — the exported file's entire content was one big fenced `json` code block; it's now an actual readable script (plain paragraphs for narration, `**SPEAKER** (emotion): "line"` for dialogue, grouped under page headings), on both the server export route and the client-side fallback used when a book has no server id yet.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **"Edit in Rehearser" renamed to "Edit Characters"** — the button always lands on Rehearser's Cast/voice-assignment screen, not general script editing, so the label now says what it does.
|
||||||
|
- **Casting text no longer edits via double-click** — only the pencil icon opens a row for editing now, so selecting/dragging across a name to assign a character (a much more common action) can't accidentally drop you into edit mode instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.12.86] — 2026-07-04
|
## [1.12.86] — 2026-07-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -814,6 +814,111 @@ async def character_deep_analysis(request: Request):
|
|||||||
return {"name": name, "analysis": analysis}
|
return {"name": name, "analysis": analysis}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-generate-prompts")
|
||||||
|
async def character_generate_prompts(request: Request):
|
||||||
|
"""Turn an already-extracted character sheet into four ready-to-use external
|
||||||
|
prompts, in one LLM call over the character's full profile (rather than
|
||||||
|
generating them incrementally passage-by-passage, where the model only
|
||||||
|
sees a fraction of the character at a time).
|
||||||
|
|
||||||
|
Body: {name, book, sheet: {...character sheet fields...}, language, llm_url, model}
|
||||||
|
Returns: {voice_design_prompt, image_prompt, silly_tavern_prompt, concept_art_prompt}
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
name: str = (data.get("name") or "").strip()
|
||||||
|
book: str = (data.get("book") or "").strip()
|
||||||
|
sheet: dict = data.get("sheet") or {}
|
||||||
|
language: str = (data.get("language") or "").strip()
|
||||||
|
_settings = _load_settings()
|
||||||
|
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||||
|
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Character name required")
|
||||||
|
|
||||||
|
def _f(key: str) -> str:
|
||||||
|
v = sheet.get(key)
|
||||||
|
if isinstance(v, list):
|
||||||
|
return ", ".join(str(x) for x in v if x)
|
||||||
|
return str(v or "").strip()
|
||||||
|
|
||||||
|
profile = "\n".join(f"{label}: {v}" for label, v in [
|
||||||
|
("Name", name), ("Aliases/titles", _f("aliases") or _f("title")),
|
||||||
|
("Archetype", _f("archetype")), ("Physical", _f("physical")), ("Clothing", _f("clothing")),
|
||||||
|
("Mannerisms", _f("mannerisms")), ("Voice/speech pattern", _f("voice_pattern")),
|
||||||
|
("Backstory", _f("backstory")), ("Motivation", _f("motivation")), ("Fears", _f("fears")),
|
||||||
|
("Relationships", _f("relationships")), ("Conflict style", _f("conflict_style")),
|
||||||
|
("Secret/flaw", _f("secret")), ("Inventory", _f("inventory")),
|
||||||
|
] if v)
|
||||||
|
|
||||||
|
lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else ""
|
||||||
|
system = (
|
||||||
|
"You are a prompt engineer who turns a fiction character's profile into four ready-to-paste "
|
||||||
|
"prompts for other tools. Use ONLY details present in the profile below; mark anything you must "
|
||||||
|
f"reasonably infer with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
|
||||||
|
"Produce exactly these four fields:\n"
|
||||||
|
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
|
||||||
|
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
|
||||||
|
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
|
||||||
|
"mention plot events — describe only how the voice should SOUND.\n"
|
||||||
|
"- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
|
||||||
|
"Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
|
||||||
|
"clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
|
||||||
|
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
|
||||||
|
"are fine.\n"
|
||||||
|
"- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
|
||||||
|
"own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
|
||||||
|
"'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
|
||||||
|
"greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
|
||||||
|
"lines showing their manner of speech). Keep each section a few lines at most.\n"
|
||||||
|
"- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
|
||||||
|
"a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
|
||||||
|
"2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
|
||||||
|
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
|
||||||
|
"white background').\n\n"
|
||||||
|
"Respond with STRICT JSON only: "
|
||||||
|
'{"voice_design_prompt":"","image_prompt":"","silly_tavern_prompt":"","concept_art_prompt":""}/no-think'
|
||||||
|
)
|
||||||
|
user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\nGenerate the four prompts now."
|
||||||
|
payload: dict = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 1600,
|
||||||
|
}
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
_msg = resp.json()["choices"][0]["message"]
|
||||||
|
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"LLM prompt generation failed: {e}")
|
||||||
|
|
||||||
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
|
result = {}
|
||||||
|
for cand in (content, _extract_json_block(content)):
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cand)
|
||||||
|
if isinstance(parsed, dict) and "voice_design_prompt" in parsed:
|
||||||
|
result = parsed
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"voice_design_prompt": str(result.get("voice_design_prompt") or "").strip(),
|
||||||
|
"image_prompt": str(result.get("image_prompt") or "").strip(),
|
||||||
|
"silly_tavern_prompt": str(result.get("silly_tavern_prompt") or "").strip(),
|
||||||
|
"concept_art_prompt": str(result.get("concept_art_prompt") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/attribute-dialogue")
|
@router.post("/api/attribute-dialogue")
|
||||||
async def attribute_dialogue(request: Request):
|
async def attribute_dialogue(request: Request):
|
||||||
@ -856,6 +961,11 @@ async def attribute_dialogue(request: Request):
|
|||||||
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
|
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
|
||||||
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
|
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
|
||||||
"starts with — or – (Spanish/French/Polish style).\n"
|
"starts with — or – (Spanish/French/Polish style).\n"
|
||||||
|
"PARAGRAPH/CHAPTER STRUCTURE: a blank line marks a real paragraph break or a chapter/scene start. "
|
||||||
|
"A very short standalone line right before a blank line (e.g. 'Chapter 1', 'Prologue', a bare number) "
|
||||||
|
"is a chapter heading — always narration/'Narrator', never dialogue. Keep blank lines as part of the "
|
||||||
|
"surrounding narration segment or their own narration segment; never invent dialogue from them and "
|
||||||
|
"never drop them from the reconstructed text.\n"
|
||||||
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
|
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
|
||||||
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
|
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
|
||||||
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
|
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
|
||||||
|
|||||||
167
routes/reader.py
167
routes/reader.py
@ -9,9 +9,12 @@ Stores each document under the writable config volume:
|
|||||||
Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB
|
Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB
|
||||||
shape so the frontend swap is mechanical.
|
shape so the frontend swap is mechanical.
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
@ -19,6 +22,7 @@ from fastapi.responses import FileResponse, Response
|
|||||||
|
|
||||||
from core.constants import CONFIG_DIR
|
from core.constants import CONFIG_DIR
|
||||||
from core.database import (
|
from core.database import (
|
||||||
|
char_get_all,
|
||||||
reader_script_delete as db_reader_script_delete,
|
reader_script_delete as db_reader_script_delete,
|
||||||
reader_script_get as db_reader_script_get,
|
reader_script_get as db_reader_script_get,
|
||||||
reader_script_list as db_reader_script_list,
|
reader_script_list as db_reader_script_list,
|
||||||
@ -216,20 +220,59 @@ def _valid_script_name(name: str) -> bool:
|
|||||||
return bool(name and name.replace("-", "").replace("_", "").isalnum())
|
return bool(name and name.replace("-", "").replace("_", "").isalnum())
|
||||||
|
|
||||||
def _cast_to_md(data: dict) -> str:
|
def _cast_to_md(data: dict) -> str:
|
||||||
"""Serialise cast JSON as a fenced code-block Markdown file."""
|
"""Render the cast as a readable Markdown script — narrator lines as plain
|
||||||
meta = {k: v for k, v in data.items() if k != "segments"}
|
paragraphs, dialogue as **SPEAKER** (emotion): "line", grouped under page
|
||||||
lines = [
|
headings from each segment's .page. This is a one-way export for reading/
|
||||||
"# Cast Script",
|
sharing; the app's own persistence is the SQLite script store, so the file
|
||||||
"",
|
doesn't need to round-trip back through _md_to_cast (that function only
|
||||||
f"**Book:** {data.get('title', '')} ",
|
still matters for pre-migration legacy .md files)."""
|
||||||
f"**Saved:** {data.get('savedAt', '')} ",
|
segments = data.get("segments") or []
|
||||||
f"**Segments:** {len(data.get('segments', []))} ",
|
roster = data.get("roster") or []
|
||||||
"",
|
saved_at = data.get("savedAt")
|
||||||
"```json",
|
saved_str = ""
|
||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
if saved_at:
|
||||||
"```",
|
try:
|
||||||
"",
|
saved_str = datetime.fromtimestamp(float(saved_at) / 1000).strftime("%Y-%m-%d %H:%M")
|
||||||
]
|
except Exception:
|
||||||
|
saved_str = str(saved_at)
|
||||||
|
speakers = sorted({
|
||||||
|
str(s.get("speaker") or "Narrator") for s in segments
|
||||||
|
if isinstance(s, dict) and s.get("type") == "dialogue" and s.get("speaker")
|
||||||
|
})
|
||||||
|
lines = [f"# {data.get('title') or 'Cast Script'}", ""]
|
||||||
|
meta_bits = []
|
||||||
|
if saved_str:
|
||||||
|
meta_bits.append(f"exported {saved_str}")
|
||||||
|
meta_bits.append(f"{len(speakers)} character{'s' if len(speakers) != 1 else ''}")
|
||||||
|
meta_bits.append(f"{len(segments)} segment{'s' if len(segments) != 1 else ''}")
|
||||||
|
lines.append(f"*{' · '.join(meta_bits)}*")
|
||||||
|
lines.append("")
|
||||||
|
if speakers:
|
||||||
|
lines.append(f"**Characters:** {', '.join(speakers)}")
|
||||||
|
lines.append("")
|
||||||
|
last_page = None
|
||||||
|
for seg in segments:
|
||||||
|
if not isinstance(seg, dict):
|
||||||
|
continue
|
||||||
|
text = str(seg.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
page = seg.get("page")
|
||||||
|
if page is not None and page != last_page:
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"## Page {page}")
|
||||||
|
lines.append("")
|
||||||
|
last_page = page
|
||||||
|
is_dialogue = seg.get("type") == "dialogue"
|
||||||
|
speaker = str(seg.get("speaker") or "Narrator")
|
||||||
|
if is_dialogue:
|
||||||
|
emotion = str(seg.get("emotion") or "").strip()
|
||||||
|
tag = f" *({emotion})*" if emotion else ""
|
||||||
|
lines.append(f'**{speaker.upper()}**{tag}: "{text}"')
|
||||||
|
else:
|
||||||
|
lines.append(text)
|
||||||
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _md_to_cast(text: str) -> dict:
|
def _md_to_cast(text: str) -> dict:
|
||||||
@ -241,6 +284,74 @@ def _md_to_cast(text: str) -> dict:
|
|||||||
return json.loads(m.group(1))
|
return json.loads(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
_CHAR_MD_SECTIONS = [
|
||||||
|
("Identity", [("Aliases / also known as", "aliases"), ("Full name", "full_name"),
|
||||||
|
("Title / role", "title"), ("Archetype", "archetype")]),
|
||||||
|
("Appearance", [("Physical", "physical"), ("Clothing", "clothing")]),
|
||||||
|
("Personality", [("Mannerisms & habits", "mannerisms"), ("Voice & speech", "voice_pattern"),
|
||||||
|
("Motivation", "motivation"), ("Fears", "fears")]),
|
||||||
|
("Story", [("Backstory", "backstory"), ("Relationships", "relationships"),
|
||||||
|
("Dark secret / fatal flaw", "secret"), ("Character arc", "arc_note")]),
|
||||||
|
("Abilities", [("Skills", "skills"), ("Capabilities", "capabilities"),
|
||||||
|
("Conflict style", "conflict_style"), ("Win condition", "win_condition")]),
|
||||||
|
("Generation prompts", [("Voice design prompt", "voice_design_prompt"),
|
||||||
|
("Image prompt", "image_prompt"),
|
||||||
|
("SillyTavern prompt", "silly_tavern_prompt"),
|
||||||
|
("Concept art prompt", "concept_art_prompt")]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _character_to_md(rec: dict) -> str:
|
||||||
|
"""Render one character-library record as a readable Markdown sheet."""
|
||||||
|
sheet = rec.get("sheet") or {}
|
||||||
|
|
||||||
|
def _s(key: str) -> str:
|
||||||
|
v = sheet.get(key)
|
||||||
|
if isinstance(v, list):
|
||||||
|
return ", ".join(str(x) for x in v if x)
|
||||||
|
return str(v or "").strip()
|
||||||
|
|
||||||
|
lines = [f"# {rec.get('name') or 'Character'}", ""]
|
||||||
|
if rec.get("book"):
|
||||||
|
lines.append(f"*{rec['book']}*")
|
||||||
|
lines.append("")
|
||||||
|
for section, fields in _CHAR_MD_SECTIONS:
|
||||||
|
rows = [(label, _s(key)) for label, key in fields]
|
||||||
|
rows = [(label, v) for label, v in rows if v]
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
lines.append(f"## {section}")
|
||||||
|
lines.append("")
|
||||||
|
for label, v in rows:
|
||||||
|
lines.append(f"**{label}:** {v}")
|
||||||
|
lines.append("")
|
||||||
|
inv = sheet.get("inventory") or []
|
||||||
|
if inv:
|
||||||
|
lines.append("## Inventory")
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(f"- {item}" for item in inv if item)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _characters_for_title(title: str) -> list[dict]:
|
||||||
|
"""Character records belonging to a book, by origin book OR tag membership
|
||||||
|
(mirrors clGetAllByTagOrBook in characters-library.js)."""
|
||||||
|
key = str(title or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
for rec in char_get_all():
|
||||||
|
book = str(rec.get("book") or "").strip().lower()
|
||||||
|
tags = [t.strip().lower() for t in str(rec.get("tags") or "").split(",")]
|
||||||
|
if book == key or key in tags:
|
||||||
|
out.append(rec)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/reader/docs/{doc_id}/scripts")
|
@router.get("/api/reader/docs/{doc_id}/scripts")
|
||||||
async def reader_list_scripts(doc_id: str):
|
async def reader_list_scripts(doc_id: str):
|
||||||
_doc_dir(doc_id)
|
_doc_dir(doc_id)
|
||||||
@ -315,16 +426,28 @@ async def reader_export_script(doc_id: str, name: str):
|
|||||||
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
filename = f"{name}.md"
|
|
||||||
title = str(data.get("title") or "").strip()
|
title = str(data.get("title") or "").strip()
|
||||||
if title:
|
safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_") or name
|
||||||
safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_")
|
# One zip bundle: the cast script plus a sheet per character — instead of
|
||||||
if safe_title:
|
# a separate browser download (and save-dialog) for every single file.
|
||||||
filename = f"{safe_title}_{name}.md"
|
characters = _characters_for_title(title)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(f"{safe_title}_cast.md", _cast_to_md(data))
|
||||||
|
seen = set()
|
||||||
|
for rec in characters:
|
||||||
|
cname = "".join(c if c.isalnum() or c in "._- " else "_" for c in str(rec.get("name") or "character")).strip() or "character"
|
||||||
|
base = cname
|
||||||
|
i = 2
|
||||||
|
while cname.lower() in seen:
|
||||||
|
cname = f"{base}_{i}"
|
||||||
|
i += 1
|
||||||
|
seen.add(cname.lower())
|
||||||
|
zf.writestr(f"characters/{cname}.md", _character_to_md(rec))
|
||||||
return Response(
|
return Response(
|
||||||
_cast_to_md(data),
|
buf.getvalue(),
|
||||||
media_type="text/markdown; charset=utf-8",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{safe_title}_{name}.zip"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.12.86">
|
<meta name="app-version" content="1.12.90">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.12.86">
|
<link rel="stylesheet" href="/static/style.css?v=1.12.90">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.12.86"></script>
|
<script src="/static/loader.js?v=1.12.90"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -74,6 +74,44 @@ function audiobookSegmentText(seg) {
|
|||||||
return `"${seg.text || ''}"`;
|
return `"${seg.text || ''}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Word-under-cursor for the casting feed WITHOUT wrapping every word in its
|
||||||
|
// own <span> — an earlier version did exactly that, and at book scale
|
||||||
|
// (1500+ segments × dozens of words) the ~100k extra DOM nodes with hover
|
||||||
|
// styles froze the page on every feed redraw. Instead this asks the browser
|
||||||
|
// which text position sits under the pointer (caretRangeFromPoint /
|
||||||
|
// caretPositionFromPoint — both native and cheap), then expands to word
|
||||||
|
// boundaries. Returns { word, range } or null; the Range provides the rect
|
||||||
|
// for the hover highlight overlay and popup anchoring.
|
||||||
|
function _abWordRangeAtPoint(x, y) {
|
||||||
|
let node = null, offset = 0;
|
||||||
|
if (document.caretRangeFromPoint) {
|
||||||
|
const r = document.caretRangeFromPoint(x, y);
|
||||||
|
if (!r) return null;
|
||||||
|
node = r.startContainer; offset = r.startOffset;
|
||||||
|
} else if (document.caretPositionFromPoint) {
|
||||||
|
const p = document.caretPositionFromPoint(x, y);
|
||||||
|
if (!p) return null;
|
||||||
|
node = p.offsetNode; offset = p.offset;
|
||||||
|
} else return null;
|
||||||
|
if (!node || node.nodeType !== 3) return null;
|
||||||
|
const text = node.nodeValue || '';
|
||||||
|
const isW = ch => ch != null && /[\p{L}\p{N}'’-]/u.test(ch);
|
||||||
|
if (offset >= text.length) offset = text.length - 1;
|
||||||
|
if (!isW(text[offset])) {
|
||||||
|
if (offset > 0 && isW(text[offset - 1])) offset--;
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
let a = offset, b = offset;
|
||||||
|
while (a > 0 && isW(text[a - 1])) a--;
|
||||||
|
while (b + 1 < text.length && isW(text[b + 1])) b++;
|
||||||
|
const word = text.slice(a, b + 1).trim();
|
||||||
|
if (!word || word.length < 2) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, a);
|
||||||
|
range.setEnd(node, b + 1);
|
||||||
|
return { word, range };
|
||||||
|
}
|
||||||
|
|
||||||
function audiobookJoinSegmentText(a, b) {
|
function audiobookJoinSegmentText(a, b) {
|
||||||
const left = String(a || '');
|
const left = String(a || '');
|
||||||
const right = String(b || '');
|
const right = String(b || '');
|
||||||
@ -208,20 +246,41 @@ function _abSafeFilename(name, fallback = 'cast') {
|
|||||||
return (s || fallback || 'cast').slice(0, 120);
|
return (s || fallback || 'cast').slice(0, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors routes/reader.py's _cast_to_md — a readable Markdown script (plain
|
||||||
|
// paragraphs for narration, **SPEAKER** (emotion): "line" for dialogue,
|
||||||
|
// grouped under page headings), used when there's no server-side bookId to
|
||||||
|
// export through. One-way export for reading/sharing, not meant to round-trip.
|
||||||
function _abCastMarkdown(data) {
|
function _abCastMarkdown(data) {
|
||||||
const payload = data || {};
|
const payload = data || {};
|
||||||
return [
|
const segments = Array.isArray(payload.segments) ? payload.segments : [];
|
||||||
'# Cast Script',
|
const speakers = [...new Set(
|
||||||
'',
|
segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker))
|
||||||
`**Book:** ${payload.title || ''} `,
|
)].sort();
|
||||||
`**Saved:** ${payload.savedAt || ''} `,
|
const lines = [`# ${payload.title || 'Cast Script'}`, ''];
|
||||||
`**Segments:** ${Array.isArray(payload.segments) ? payload.segments.length : 0} `,
|
const metaBits = [];
|
||||||
'',
|
if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' '));
|
||||||
'```json',
|
metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`);
|
||||||
JSON.stringify(payload, null, 2),
|
metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`);
|
||||||
'```',
|
lines.push(`*${metaBits.join(' · ')}*`, '');
|
||||||
'',
|
if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, '');
|
||||||
].join('\n');
|
let lastPage = null;
|
||||||
|
for (const seg of segments) {
|
||||||
|
const text = String(seg?.text || '').trim();
|
||||||
|
if (!text) continue;
|
||||||
|
if (seg.page != null && seg.page !== lastPage) {
|
||||||
|
lines.push('---', '', `## Page ${seg.page}`, '');
|
||||||
|
lastPage = seg.page;
|
||||||
|
}
|
||||||
|
if (seg.type === 'dialogue') {
|
||||||
|
const speaker = String(seg.speaker || 'Narrator');
|
||||||
|
const tag = seg.emotion ? ` *(${seg.emotion})*` : '';
|
||||||
|
lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`);
|
||||||
|
} else {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration for drafts saved before segments carried a .page number:
|
// One-time migration for drafts saved before segments carried a .page number:
|
||||||
@ -284,7 +343,14 @@ function _abSaveDraft(segs, roster, text, done, total) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _abExportBusy = false;
|
||||||
async function audiobookExportCastMd() {
|
async function audiobookExportCastMd() {
|
||||||
|
// Re-entry guard: while the page is busy, a user's queued-up clicks can all
|
||||||
|
// fire at once when the main thread unblocks — without this, that meant a
|
||||||
|
// burst of identical downloads and one save-dialog per copy to cancel.
|
||||||
|
if (_abExportBusy) return;
|
||||||
|
_abExportBusy = true;
|
||||||
|
setTimeout(() => { _abExportBusy = false; }, 2000);
|
||||||
const segs = _audiobook.segments || [];
|
const segs = _audiobook.segments || [];
|
||||||
if (!segs.length) { toast('No cast to export', 'error'); return; }
|
if (!segs.length) { toast('No cast to export', 'error'); return; }
|
||||||
const bookId = _abBookId();
|
const bookId = _abBookId();
|
||||||
@ -299,7 +365,7 @@ async function audiobookExportCastMd() {
|
|||||||
const blob = await r.blob();
|
const blob = await r.blob();
|
||||||
const disp = r.headers.get('Content-Disposition') || '';
|
const disp = r.headers.get('Content-Disposition') || '';
|
||||||
const match = disp.match(/filename="([^"]+)"/i);
|
const match = disp.match(/filename="([^"]+)"/i);
|
||||||
const name = match?.[1] || `${_abSafeFilename(title)}_cast.md`;
|
const name = match?.[1] || `${_abSafeFilename(title)}_cast.zip`;
|
||||||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||||||
else {
|
else {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@ -308,7 +374,7 @@ async function audiobookExportCastMd() {
|
|||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 500);
|
setTimeout(() => URL.revokeObjectURL(a.href), 500);
|
||||||
}
|
}
|
||||||
toast('Exported cast Markdown', 'success');
|
toast('Exported cast + character sheets (.zip)', 'success');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('Server export failed, downloading local cast copy', 'error');
|
toast('Server export failed, downloading local cast copy', 'error');
|
||||||
@ -709,7 +775,19 @@ function audiobookScopeText() {
|
|||||||
lastPage = pg;
|
lastPage = pg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const raw = idxs.map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
|
// Join units with a blank line wherever one starts a real paragraph (per
|
||||||
|
// readerMarkParagraphBreaks/.paraStart) instead of unconditionally with a
|
||||||
|
// single space — otherwise chapter headings and every paragraph break in
|
||||||
|
// the book collapse into one run-on blob before the LLM ever sees the text.
|
||||||
|
let raw = '';
|
||||||
|
for (const i of idxs) {
|
||||||
|
const u = readerState.sentences[i];
|
||||||
|
if (!u?.text) continue;
|
||||||
|
raw += !raw ? u.text : (u.paraStart ? '\n\n' : ' ') + u.text;
|
||||||
|
}
|
||||||
|
// Collapse only horizontal whitespace runs and cap excessive blank lines —
|
||||||
|
// a plain /\s+/ collapse here would erase the \n\n paragraph markers just inserted.
|
||||||
|
raw = raw.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
||||||
// Locate each page anchor in the final text → page-break offsets.
|
// Locate each page anchor in the final text → page-break offsets.
|
||||||
let from = 0;
|
let from = 0;
|
||||||
@ -881,6 +959,9 @@ WICHTIGE DEFINITION VON DIALOG:
|
|||||||
Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden.
|
Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden.
|
||||||
Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).
|
Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).
|
||||||
|
|
||||||
|
ABSATZ- UND KAPITELSTRUKTUR:
|
||||||
|
Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.
|
||||||
|
|
||||||
GRAMMATIK-REGELN FÜR NARRATION:
|
GRAMMATIK-REGELN FÜR NARRATION:
|
||||||
- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
|
- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
|
||||||
- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.
|
- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.
|
||||||
@ -958,6 +1039,14 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.'
|
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Paragraph/heading structure is now preserved as blank lines upstream
|
||||||
|
// (readerMarkParagraphBreaks); tell older saved prompts how to read it.
|
||||||
|
if (!/ABSATZ- UND KAPITELSTRUKTUR/.test(p)) {
|
||||||
|
p = p.replace(
|
||||||
|
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).',
|
||||||
|
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nABSATZ- UND KAPITELSTRUKTUR:\nEine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer \'narration\'/\'Narrator\', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.'
|
||||||
|
);
|
||||||
|
}
|
||||||
return p;
|
return p;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1265,7 +1354,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
for (const { name, regex } of _hlCache.list) {
|
for (const { name, regex } of _hlCache.list) {
|
||||||
html = html.replace(regex, (match) => `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`);
|
html = html.replace(regex, (match) => `<span class="ab-name-hit" data-name="${escHtml(name)}" style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
@ -1608,13 +1697,68 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
<span class="ab-char-dot" style="background:${color}">${escHtml((n||'?')[0].toUpperCase())}</span>
|
<span class="ab-char-dot" style="background:${color}">${escHtml((n||'?')[0].toUpperCase())}</span>
|
||||||
<span class="ab-char-name">${escHtml(n)}</span>
|
<span class="ab-char-name">${escHtml(n)}</span>
|
||||||
<b class="ab-char-count">${roster.get(n)?.count||0}</b>
|
<b class="ab-char-count">${roster.get(n)?.count||0}</b>
|
||||||
|
<button class="ab-char-alias-btn" data-name="${escHtml(n)}" title="Add alias / also known as"><span class="mdi mdi-tag-plus-outline"></span></button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
chars.querySelectorAll('.ab-char-item').forEach(item => {
|
chars.querySelectorAll('.ab-char-item').forEach(item => {
|
||||||
item.addEventListener('click', () => _abSelectChar(item.dataset.name));
|
item.addEventListener('click', () => _abSelectChar(item.dataset.name));
|
||||||
});
|
});
|
||||||
|
chars.querySelectorAll('.ab-char-alias-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); _abOpenAliasPopup(btn.dataset.name, btn); });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Quick "also known as" shortcut right in the sidebar, so adding an alias
|
||||||
|
// (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker)
|
||||||
|
// doesn't require leaving the casting screen for the full Character Library
|
||||||
|
// editor. Writes through the same clUpsert used everywhere else, so the
|
||||||
|
// alias is immediately shared with Rehearser/Character sheets too.
|
||||||
|
let _abAliasPopup = null;
|
||||||
|
function _abCloseAliasPopup() { if (_abAliasPopup) { _abAliasPopup.remove(); _abAliasPopup = null; } }
|
||||||
|
function _abOpenAliasPopup(name, anchorEl) {
|
||||||
|
_abCloseAliasPopup();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'ab-alias-popup';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ab-alias-popup-title">Also known as — <b>${escHtml(name)}</b></div>
|
||||||
|
<input type="text" class="ab-alias-popup-inp" placeholder="e.g. Garthai, der Fremde" autocomplete="off">
|
||||||
|
<div class="ab-alias-popup-actions">
|
||||||
|
<button type="button" class="btn-secondary btn-sm ab-alias-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn-primary btn-sm ab-alias-save">Add</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
el.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px';
|
||||||
|
el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 120) + 'px';
|
||||||
|
const inp = el.querySelector('.ab-alias-popup-inp');
|
||||||
|
setTimeout(() => inp.focus(), 30);
|
||||||
|
const save = async () => {
|
||||||
|
const alias = inp.value.trim();
|
||||||
|
if (!alias) { _abCloseAliasPopup(); return; }
|
||||||
|
try {
|
||||||
|
const book = window.readerState?.title || '';
|
||||||
|
const rec = await clUpsert(book, { name, aliases: alias });
|
||||||
|
if (rec) { registerCharacterRecord(rec); renderRoster(); if (_hlCache) _hlCache.ver = -1; }
|
||||||
|
toast(`"${alias}" added as an alias for ${name}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
toast('Could not save alias: ' + (err.message || err), 'error');
|
||||||
|
}
|
||||||
|
_abCloseAliasPopup();
|
||||||
|
};
|
||||||
|
el.querySelector('.ab-alias-save').addEventListener('click', save);
|
||||||
|
el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup());
|
||||||
|
inp.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); _abCloseAliasPopup(); }
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', function onDoc(e) {
|
||||||
|
if (!el.contains(e.target) && e.target !== anchorEl) { _abCloseAliasPopup(); document.removeEventListener('click', onDoc); }
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
_abAliasPopup = el;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const title = window.readerState?.title || '';
|
const title = window.readerState?.title || '';
|
||||||
try {
|
try {
|
||||||
@ -1853,7 +1997,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}`;
|
row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}`;
|
||||||
row.__seg = s;
|
row.__seg = s;
|
||||||
row.innerHTML = `<span class="ab-cv-row-tools">
|
row.innerHTML = `<span class="ab-cv-row-tools">
|
||||||
<button class="ab-cv-row-tool ab-cv-edit-text" type="button" title="Edit text (or double-click)"><span class="mdi mdi-pencil-outline"></span></button>
|
<button class="ab-cv-row-tool ab-cv-edit-text" type="button" title="Edit text"><span class="mdi mdi-pencil-outline"></span></button>
|
||||||
<button class="ab-cv-row-tool ab-cv-merge-prev" type="button" title="Merge with previous segment"><span class="mdi mdi-arrow-collapse-up"></span></button>
|
<button class="ab-cv-row-tool ab-cv-merge-prev" type="button" title="Merge with previous segment"><span class="mdi mdi-arrow-collapse-up"></span></button>
|
||||||
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
|
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
|
||||||
</span>
|
</span>
|
||||||
@ -2082,6 +2226,52 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
_abPersistManualEdit();
|
_abPersistManualEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by clicking the speaker label AND clicking/dragging a name inside
|
||||||
|
// the narration text itself — same popup, optionally pre-filled with the
|
||||||
|
// word(s) you clicked so you don't have to retype an exact match.
|
||||||
|
function _abOpenAssignPopup(row, anchorEl, prefill) {
|
||||||
|
if (!row || !row.__seg || row.classList.contains('is-processing')) return;
|
||||||
|
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
|
||||||
|
assignModeSeg = row.__seg;
|
||||||
|
assignModeRow = row;
|
||||||
|
row.classList.add('is-assigning');
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
|
||||||
|
assignPopup.style.display = 'flex';
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
const top = Math.min(rect.bottom + 4, window.innerHeight - 300);
|
||||||
|
assignPopup.style.top = top + 'px';
|
||||||
|
assignPopup.style.left = rect.left + 'px';
|
||||||
|
|
||||||
|
const list = assignPopup.querySelector('.ab-cv-popup-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
const narrBtn = document.createElement('div');
|
||||||
|
narrBtn.dataset.name = 'Narrator';
|
||||||
|
narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;';
|
||||||
|
narrBtn.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
|
||||||
|
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
|
||||||
|
narrBtn.onmouseout = () => narrBtn.style.background = 'transparent';
|
||||||
|
narrBtn.onclick = () => assignName('Narrator');
|
||||||
|
list.appendChild(narrBtn);
|
||||||
|
|
||||||
|
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||||
|
for (const [n, info] of items) {
|
||||||
|
const btn = document.createElement('div');
|
||||||
|
btn.dataset.name = n;
|
||||||
|
btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);';
|
||||||
|
btn.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
|
||||||
|
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
||||||
|
btn.onmouseout = () => btn.style.background = 'transparent';
|
||||||
|
btn.onclick = () => assignName(n);
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = assignPopup.querySelector('input');
|
||||||
|
inp.value = prefill || '';
|
||||||
|
setTimeout(() => { inp.focus(); if (prefill) inp.dispatchEvent(new Event('input')); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker);
|
const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker);
|
||||||
const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker);
|
const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker);
|
||||||
const _abMergedSegment = (a, b) => {
|
const _abMergedSegment = (a, b) => {
|
||||||
@ -2095,6 +2285,11 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
type,
|
type,
|
||||||
emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '',
|
emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '',
|
||||||
text: audiobookJoinSegmentText(a.text || '', b.text || ''),
|
text: audiobookJoinSegmentText(a.text || '', b.text || ''),
|
||||||
|
// Anchor the merged row at its earlier segment's page — merging never
|
||||||
|
// built this at all before, so a merge would silently drop the row's
|
||||||
|
// page number and it would render as if it belonged to whatever page
|
||||||
|
// card came before it.
|
||||||
|
page: a.page ?? b.page,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const _abMergeByRow = (row, dir) => {
|
const _abMergeByRow = (row, dir) => {
|
||||||
@ -2117,10 +2312,10 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
toast('Segments merged', 'success');
|
toast('Segments merged', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
feed.addEventListener('dblclick', e => {
|
// Editing is pencil-icon only (see the click handler's .ab-cv-edit-text
|
||||||
const txt = e.target.closest('.ab-cv-txt');
|
// branch) — double-click was removed so selecting/dragging text to assign
|
||||||
if (txt) _abStartRowEdit(txt.closest('.ab-cv-row'));
|
// a character (see the .ab-cv-txt mousedown/mouseup handling below) can't
|
||||||
});
|
// accidentally drop you into edit mode instead.
|
||||||
|
|
||||||
feed.addEventListener('click', e => {
|
feed.addEventListener('click', e => {
|
||||||
const pageLabel = e.target.closest('.ab-cv-page-label[data-page]');
|
const pageLabel = e.target.closest('.ab-cv-page-label[data-page]');
|
||||||
@ -2146,52 +2341,82 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
}
|
}
|
||||||
const spk = e.target.closest('.ab-cv-spk');
|
const spk = e.target.closest('.ab-cv-spk');
|
||||||
if (spk) {
|
if (spk) {
|
||||||
const row = spk.closest('.ab-cv-row');
|
_abOpenAssignPopup(spk.closest('.ab-cv-row'), spk, '');
|
||||||
if (row && row.__seg && !row.classList.contains('is-processing')) {
|
return;
|
||||||
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
|
}
|
||||||
assignModeSeg = row.__seg;
|
// Click a name/word inside the narration text itself — same popup,
|
||||||
assignModeRow = row;
|
// pre-filled with the clicked word so you don't have to retype it.
|
||||||
row.classList.add('is-assigning');
|
// Dragging across several words is handled by the native-selection
|
||||||
window.getSelection().removeAllRanges();
|
// listener below instead of a custom span-drag tracker, so it can't
|
||||||
|
// conflict with the existing "select text to split this segment" flow,
|
||||||
assignPopup.style.display = 'flex';
|
// which also reacts to window.getSelection() over the same text. That
|
||||||
const rect = spk.getBoundingClientRect();
|
// listener clears the selection once it acts, so this guard (rather than
|
||||||
// ensure popup doesn't go off bottom of screen
|
// just isCollapsed) stops the click that follows a drag's mouseup from
|
||||||
const top = Math.min(rect.bottom + 4, window.innerHeight - 300);
|
// re-opening the popup with just the single word under the pointer.
|
||||||
assignPopup.style.top = top + 'px';
|
if (_abJustHandledSelection) { _abJustHandledSelection = false; return; }
|
||||||
assignPopup.style.left = rect.left + 'px';
|
const txtEl = e.target.closest('.ab-cv-txt');
|
||||||
|
if (txtEl && window.getSelection().isCollapsed) {
|
||||||
const list = assignPopup.querySelector('.ab-cv-popup-list');
|
const row = txtEl.closest('.ab-cv-row');
|
||||||
list.innerHTML = '';
|
const nameHit = e.target.closest('.ab-name-hit');
|
||||||
|
if (nameHit) {
|
||||||
// Always show Narrator as the first option
|
_abOpenAssignPopup(row, nameHit, nameHit.dataset.name || nameHit.textContent.trim());
|
||||||
const narrBtn = document.createElement('div');
|
} else {
|
||||||
narrBtn.dataset.name = 'Narrator';
|
const hit = _abWordRangeAtPoint(e.clientX, e.clientY);
|
||||||
narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;';
|
if (hit) {
|
||||||
narrBtn.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
|
const rect = hit.range.getBoundingClientRect();
|
||||||
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
|
_abOpenAssignPopup(row, { getBoundingClientRect: () => rect }, hit.word);
|
||||||
narrBtn.onmouseout = () => narrBtn.style.background = 'transparent';
|
|
||||||
narrBtn.onclick = () => assignName('Narrator');
|
|
||||||
list.appendChild(narrBtn);
|
|
||||||
|
|
||||||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
|
||||||
for (const [n, info] of items) {
|
|
||||||
const btn = document.createElement('div');
|
|
||||||
btn.dataset.name = n;
|
|
||||||
btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);';
|
|
||||||
btn.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
|
|
||||||
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
|
||||||
btn.onmouseout = () => btn.style.background = 'transparent';
|
|
||||||
btn.onclick = () => assignName(n);
|
|
||||||
list.appendChild(btn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inp = assignPopup.querySelector('input');
|
|
||||||
inp.value = '';
|
|
||||||
setTimeout(() => inp.focus(), 50);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let _abJustHandledSelection = false;
|
||||||
|
|
||||||
|
// Hover highlight for the word under the cursor — one reused overlay div
|
||||||
|
// positioned from the caret-range rect, instead of per-word spans (see
|
||||||
|
// _abWordRangeAtPoint for why spans froze the page at book scale).
|
||||||
|
let _abHoverHL = document.getElementById('ab-word-hover');
|
||||||
|
if (!_abHoverHL) {
|
||||||
|
_abHoverHL = document.createElement('div');
|
||||||
|
_abHoverHL.id = 'ab-word-hover';
|
||||||
|
_abHoverHL.className = 'ab-word-hover';
|
||||||
|
_abHoverHL.hidden = true;
|
||||||
|
document.body.appendChild(_abHoverHL);
|
||||||
|
}
|
||||||
|
let _abHoverRaf = 0;
|
||||||
|
let _abHoverPt = null;
|
||||||
|
const _abHideHoverHL = () => { _abHoverHL.hidden = true; };
|
||||||
|
feed.addEventListener('mousemove', e => {
|
||||||
|
_abHoverPt = { x: e.clientX, y: e.clientY, target: e.target };
|
||||||
|
if (_abHoverRaf) return;
|
||||||
|
_abHoverRaf = requestAnimationFrame(() => {
|
||||||
|
_abHoverRaf = 0;
|
||||||
|
const pt = _abHoverPt;
|
||||||
|
if (!pt || !pt.target?.closest) return;
|
||||||
|
const txtEl = pt.target.closest('.ab-cv-txt');
|
||||||
|
if (!txtEl || pt.target.closest('.ab-name-hit') || feed.querySelector('.ab-cv-edit-ta')) { _abHideHoverHL(); return; }
|
||||||
|
const hit = _abWordRangeAtPoint(pt.x, pt.y);
|
||||||
|
if (!hit) { _abHideHoverHL(); return; }
|
||||||
|
const r = hit.range.getBoundingClientRect();
|
||||||
|
if (!r.width) { _abHideHoverHL(); return; }
|
||||||
|
_abHoverHL.style.left = (r.left - 2) + 'px';
|
||||||
|
_abHoverHL.style.top = (r.top - 1) + 'px';
|
||||||
|
_abHoverHL.style.width = (r.width + 4) + 'px';
|
||||||
|
_abHoverHL.style.height = (r.height + 2) + 'px';
|
||||||
|
_abHoverHL.hidden = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
feed.addEventListener('mouseleave', _abHideHoverHL);
|
||||||
|
feed.addEventListener('scroll', _abHideHoverHL, { passive: true });
|
||||||
|
feed.addEventListener('dblclick', e => {
|
||||||
|
const hit = e.target.closest('.ab-name-hit');
|
||||||
|
if (!hit) return; // only a known name/alias can fast-assign; unknown text still needs the popup
|
||||||
|
const row = hit.closest('.ab-cv-row');
|
||||||
|
const name = hit.dataset.name;
|
||||||
|
if (row && name) {
|
||||||
|
_abOpenAssignPopup(row, hit, '');
|
||||||
|
assignName(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let splitBtn = document.getElementById('ab-cv-split-btn');
|
let splitBtn = document.getElementById('ab-cv-split-btn');
|
||||||
if (!splitBtn) {
|
if (!splitBtn) {
|
||||||
@ -2285,15 +2510,28 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
});
|
});
|
||||||
|
|
||||||
feed.addEventListener('mouseup', () => {
|
feed.addEventListener('mouseup', () => {
|
||||||
if (!assignModeSeg) return;
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel.isCollapsed && feed.contains(sel.anchorNode)) {
|
if (sel.isCollapsed || !feed.contains(sel.anchorNode)) return;
|
||||||
const text = sel.toString().trim();
|
const text = sel.toString().trim();
|
||||||
if (text && text.length < 40 && !text.includes('\n')) {
|
if (!text || text.length >= 40 || text.includes('\n')) return;
|
||||||
assignName(text);
|
_abJustHandledSelection = true; // stop the click that follows this mouseup from also firing
|
||||||
sel.removeAllRanges();
|
if (assignModeSeg) {
|
||||||
}
|
// Popup already open (e.g. from clicking the speaker label) — a short
|
||||||
|
// selection refines/confirms the assignment directly, as before.
|
||||||
|
assignName(text);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// No popup yet: dragging across a name inside the narration text (e.g. a
|
||||||
|
// multi-word name like "Sharraz Garthai" the roster doesn't have) opens
|
||||||
|
// the popup pre-filled with the dragged text instead of doing nothing.
|
||||||
|
const anchorEl = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode : sel.anchorNode;
|
||||||
|
const txtSpan = anchorEl.closest('.ab-cv-txt');
|
||||||
|
if (!txtSpan) { _abJustHandledSelection = false; return; }
|
||||||
|
const row = txtSpan.closest('.ab-cv-row');
|
||||||
|
if (!row) { _abJustHandledSelection = false; return; }
|
||||||
|
_abOpenAssignPopup(row, anchorEl, text);
|
||||||
|
sel.removeAllRanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
chars.addEventListener('click', e => {
|
chars.addEventListener('click', e => {
|
||||||
@ -2506,7 +2744,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
const continueBtnHtml = resumable
|
const continueBtnHtml = resumable
|
||||||
? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>`
|
? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>`
|
||||||
: '';
|
: '';
|
||||||
foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Generate character sheets and match voices to each found character"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Export this cast as a Markdown file"><span class="mdi mdi-file-download-outline"></span> Export cast .md</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Open in Script Rehearser to assign voices and synthesise"><span class="mdi mdi-drama-masks"></span> Edit in Rehearser</button>`;
|
foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Generate character sheets and match voices to each found character"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`;
|
||||||
|
|
||||||
const runVerificationPass = () => {
|
const runVerificationPass = () => {
|
||||||
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern.
|
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern.
|
||||||
@ -2528,6 +2766,9 @@ DEDUKTIONS-WERKZEUGE (wende sie in dieser Reihenfolge an):
|
|||||||
DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN:
|
DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN:
|
||||||
Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen.
|
Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen.
|
||||||
|
|
||||||
|
ABSATZ- UND KAPITELSTRUKTUR:
|
||||||
|
Eine Leerzeile markiert einen Absatzwechsel oder Kapitel-/Szenenanfang. Eine sehr kurze, alleinstehende Zeile vor einer Leerzeile ist eine Kapitelüberschrift — 'narration'/'Narrator', niemals Dialog.
|
||||||
|
|
||||||
GRAMMATIK-CHECK FÜR NARRATION:
|
GRAMMATIK-CHECK FÜR NARRATION:
|
||||||
- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name.
|
- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name.
|
||||||
- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration.
|
- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration.
|
||||||
@ -2619,7 +2860,7 @@ ABSOLUTE REGELN:
|
|||||||
const foot = panel.querySelector('#ab-cv-foot');
|
const foot = panel.querySelector('#ab-cv-foot');
|
||||||
const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0;
|
const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0;
|
||||||
foot.hidden = false;
|
foot.hidden = false;
|
||||||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-stop-circle-outline"></span> ${escHtml(message)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-stopped-back" style="margin-right:8px;"><span class="mdi mdi-arrow-left"></span> Back to reader</button>${hasSegments ? '<button class="btn-secondary btn-sm" id="ab-cv-stopped-review" style="margin-right:8px;"><span class="mdi mdi-drama-masks"></span> Edit in Rehearser</button>' : ''}<button class="btn-primary btn-sm" id="ab-cv-stopped-recast"><span class="mdi mdi-refresh"></span> Cast again</button>`;
|
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-stop-circle-outline"></span> ${escHtml(message)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-stopped-back" style="margin-right:8px;"><span class="mdi mdi-arrow-left"></span> Back to reader</button>${hasSegments ? '<button class="btn-secondary btn-sm" id="ab-cv-stopped-review" style="margin-right:8px;"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>' : ''}<button class="btn-primary btn-sm" id="ab-cv-stopped-recast"><span class="mdi mdi-refresh"></span> Cast again</button>`;
|
||||||
|
|
||||||
foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel);
|
foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel);
|
||||||
foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => {
|
foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => {
|
||||||
|
|||||||
@ -54,7 +54,8 @@ function csRehearserText() {
|
|||||||
const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
|
const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
|
||||||
'attribute_high', 'attribute_low', 'skills', 'capabilities',
|
'attribute_high', 'attribute_low', 'skills', 'capabilities',
|
||||||
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
|
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
|
||||||
'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt'];
|
'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt',
|
||||||
|
'silly_tavern_prompt', 'concept_art_prompt'];
|
||||||
const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f));
|
const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f));
|
||||||
const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
|
const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
|
||||||
const CS_ALIAS_MAX_TOKENS = 12;
|
const CS_ALIAS_MAX_TOKENS = 12;
|
||||||
|
|||||||
@ -13,7 +13,9 @@ const CL_EDIT_FIELDS = [
|
|||||||
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
|
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
|
||||||
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
|
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
|
||||||
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
|
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
|
||||||
['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['secret', 'Dark Secret / Fatal Flaw'],
|
['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'],
|
||||||
|
['silly_tavern_prompt', 'SillyTavern Character Prompt'], ['concept_art_prompt', 'Concept Art Prompt'],
|
||||||
|
['secret', 'Dark Secret / Fatal Flaw'],
|
||||||
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
|
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -61,8 +61,13 @@ function splitTextIntoChunks(text, maxLen = 800) {
|
|||||||
|
|
||||||
// Gather segments from each line: sentences first, then any remaining line text.
|
// Gather segments from each line: sentences first, then any remaining line text.
|
||||||
// This ensures newline-delimited text (e.g. German bullet lists) gets split too.
|
// This ensures newline-delimited text (e.g. German bullet lists) gets split too.
|
||||||
|
// A blank line (paragraph break) becomes a '\x01' marker so the chunk-builder
|
||||||
|
// below can keep it as a blank line instead of flattening it to a space —
|
||||||
|
// otherwise every paragraph/chapter break in a book collapses into one run-on
|
||||||
|
// block by the time text reaches an LLM or TTS call.
|
||||||
const segments = [];
|
const segments = [];
|
||||||
for (const line of safe.split('\n')) {
|
for (const line of safe.split('\n')) {
|
||||||
|
if (!line.trim()) { if (segments.length) segments.push('\x01'); continue; }
|
||||||
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
|
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
|
||||||
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
|
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
|
||||||
segments.push(...sentences);
|
segments.push(...sentences);
|
||||||
@ -72,10 +77,13 @@ function splitTextIntoChunks(text, maxLen = 800) {
|
|||||||
|
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let cur = '';
|
let cur = '';
|
||||||
|
let paraPending = false;
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
const joined = cur ? cur + ' ' + seg.trim() : seg.trim();
|
if (seg === '\x01') { paraPending = true; continue; }
|
||||||
|
const joined = cur ? cur + (paraPending ? '\n\n' : ' ') + seg.trim() : seg.trim();
|
||||||
if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); }
|
if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); }
|
||||||
else cur = joined;
|
else cur = joined;
|
||||||
|
paraPending = false;
|
||||||
}
|
}
|
||||||
if (cur.trim()) chunks.push(restore(cur.trim()));
|
if (cur.trim()) chunks.push(restore(cur.trim()));
|
||||||
return chunks.length ? chunks : [text];
|
return chunks.length ? chunks : [text];
|
||||||
|
|||||||
@ -292,6 +292,21 @@ function _lcdSectionFull(icon, label, fields) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A collapsible "ready to copy" box for one of the four external-tool prompts
|
||||||
|
// (Voice Design / Character Image / SillyTavern / Concept Art). Closed by
|
||||||
|
// default; editable in place via the same contenteditable+data-sheet-key
|
||||||
|
// convention the rest of the detail view uses, so edits autosave for free.
|
||||||
|
function _lcdPromptBox(label, value, sheetKey) {
|
||||||
|
const has = !!(value && String(value).trim());
|
||||||
|
return '<details class="lcd-prompt-box">'
|
||||||
|
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
||||||
|
+ '<div class="lcd-prompt-body">'
|
||||||
|
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
|
||||||
|
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</details>';
|
||||||
|
}
|
||||||
|
|
||||||
function _lcdFieldEdit(label, value, sheetKey) {
|
function _lcdFieldEdit(label, value, sheetKey) {
|
||||||
const v = _libStr(value);
|
const v = _libStr(value);
|
||||||
return '<div class="lcd-field">'
|
return '<div class="lcd-field">'
|
||||||
@ -371,6 +386,16 @@ async function _charDetailPage(rec, allChars) {
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
) : '';
|
) : '';
|
||||||
|
|
||||||
|
const promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
|
||||||
|
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts'
|
||||||
|
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompts"><span class="mdi mdi-creation"></span> Generate</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt')
|
||||||
|
+ _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt')
|
||||||
|
+ _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt')
|
||||||
|
+ _lcdPromptBox('Concept Art Prompt', sh.concept_art_prompt, 'concept_art_prompt')
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
const relText = _libStr(sh.relationships).toLowerCase();
|
const relText = _libStr(sh.relationships).toLowerCase();
|
||||||
const relHits = (allChars || [])
|
const relHits = (allChars || [])
|
||||||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||||||
@ -490,6 +515,7 @@ async function _charDetailPage(rec, allChars) {
|
|||||||
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
|
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
|
||||||
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
|
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
|
||||||
])
|
])
|
||||||
|
+ promptsHtml
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ sourcesHtml
|
+ sourcesHtml
|
||||||
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||||||
@ -552,6 +578,49 @@ async function _charDetailPage(rec, allChars) {
|
|||||||
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||||||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); });
|
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); });
|
||||||
|
|
||||||
|
// Generation Prompts section: copy buttons + one-call generation of all four
|
||||||
|
// external-tool prompts (Voice Design / Image / SillyTavern / Concept Art)
|
||||||
|
// from the character's full profile.
|
||||||
|
pg.querySelectorAll('.lcd-prompt-copy').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', async function () {
|
||||||
|
const box = btn.closest('.lcd-prompt-body');
|
||||||
|
const text = box?.querySelector('.lcd-prompt-text')?.textContent.trim() || '';
|
||||||
|
if (!text) { toast('Nothing to copy yet — click Generate first', 'error'); return; }
|
||||||
|
if (typeof copyText === 'function') await copyText(text);
|
||||||
|
toast('Prompt copied', 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
pg.querySelector('.lcd-gen-prompts')?.addEventListener('click', async function () {
|
||||||
|
const btn = this;
|
||||||
|
const orig = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
|
||||||
|
try {
|
||||||
|
const sh2 = rec.sheet || {};
|
||||||
|
const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' ');
|
||||||
|
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
|
||||||
|
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
|
||||||
|
const r = await fetch('/api/character-generate-prompts', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||||||
|
const d = await r.json();
|
||||||
|
if (!rec.sheet) rec.sheet = {};
|
||||||
|
['voice_design_prompt', 'image_prompt', 'silly_tavern_prompt', 'concept_art_prompt'].forEach(function (k) {
|
||||||
|
if (d[k]) rec.sheet[k] = d[k];
|
||||||
|
});
|
||||||
|
rec.updated = new Date();
|
||||||
|
if (typeof clPut === 'function') await clPut(rec);
|
||||||
|
toast('Generation prompts created', 'success');
|
||||||
|
_charDetailPage(rec, allChars); // re-render so the boxes show the new content
|
||||||
|
} catch (err) {
|
||||||
|
toast('Prompt generation failed: ' + (err.message || err), 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const slider = pg.querySelector('.lcd-align-slider');
|
const slider = pg.querySelector('.lcd-align-slider');
|
||||||
const sliderVal = pg.querySelector('.lcd-align-slider-val');
|
const sliderVal = pg.querySelector('.lcd-align-slider-val');
|
||||||
const arcEl = pg.querySelector('.lcd-align-arc');
|
const arcEl = pg.querySelector('.lcd-align-arc');
|
||||||
|
|||||||
@ -308,7 +308,11 @@ function readerBuildSentences(words) {
|
|||||||
const endRe = /[.!?]["'”’)\]]?$/;
|
const endRe = /[.!?]["'”’)\]]?$/;
|
||||||
const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
|
const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
|
||||||
for (const w of words) {
|
for (const w of words) {
|
||||||
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null };
|
// A detected paragraph break (readerMarkParagraphBreaks, PDF only) always
|
||||||
|
// ends the current sentence, even mid-punctuation, so the boundary isn't
|
||||||
|
// lost — it's carried as .paraStart for audiobookScopeText/TTS pacing.
|
||||||
|
if (w.para && cur && cur.words.length) { sentences.push(cur); cur = null; }
|
||||||
|
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, paraStart: !!w.para };
|
||||||
cur.words.push(w);
|
cur.words.push(w);
|
||||||
cur.text += (cur.text ? ' ' : '') + w.text;
|
cur.text += (cur.text ? ' ' : '') + w.text;
|
||||||
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
|
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
|
||||||
@ -327,7 +331,7 @@ function readerBuildSentences(words) {
|
|||||||
// • page — merge a whole PDF page (or ~2000-char blocks of text)
|
// • page — merge a whole PDF page (or ~2000-char blocks of text)
|
||||||
function readerGroupUnits(base, mode) {
|
function readerGroupUnits(base, mode) {
|
||||||
if (mode === 'sentence' || !base.length) {
|
if (mode === 'sentence' || !base.length) {
|
||||||
return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null }));
|
return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null, paraStart: !!s.paraStart }));
|
||||||
}
|
}
|
||||||
const maxChars = mode === 'page' ? 2000 : 500;
|
const maxChars = mode === 'page' ? 2000 : 500;
|
||||||
const isPdf = readerState.mode === 'pdf';
|
const isPdf = readerState.mode === 'pdf';
|
||||||
@ -344,14 +348,44 @@ function readerGroupUnits(base, mode) {
|
|||||||
);
|
);
|
||||||
const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
|
const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
|
||||||
if (boundary || tooLong) { units.push(cur); cur = null; }
|
if (boundary || tooLong) { units.push(cur); cur = null; }
|
||||||
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k };
|
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k, paraStart: !!s.paraStart };
|
||||||
cur.text += (cur.text ? ' ' : '') + s.text;
|
// A real paragraph break inside a merged (paragraph/page) unit is kept as
|
||||||
|
// a blank line rather than collapsed to a space, so casting/synthesis can
|
||||||
|
// still tell paragraphs apart even when several are merged into one unit.
|
||||||
|
cur.text += !cur.text ? s.text : (s.paraStart ? '\n\n' : ' ') + s.text;
|
||||||
cur.words.push(...s.words);
|
cur.words.push(...s.words);
|
||||||
}
|
}
|
||||||
if (cur) units.push(cur);
|
if (cur) units.push(cur);
|
||||||
return units;
|
return units;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flag the first word of each line that starts a new paragraph, by grouping
|
||||||
|
// words into visual lines (shared `top`) and comparing consecutive lines'
|
||||||
|
// vertical gap against the page's typical single-line spacing — a gap clearly
|
||||||
|
// bigger than normal (paragraph spacing, or the gap after a heading/image)
|
||||||
|
// marks a paragraph break. This is the only place paragraph structure is
|
||||||
|
// ever recovered for PDFs; downstream (readerBuildSentences, audiobookScopeText)
|
||||||
|
// just honours the `.para` flag it sets.
|
||||||
|
function readerMarkParagraphBreaks(pageWords) {
|
||||||
|
if (!pageWords.length) return;
|
||||||
|
const lines = [];
|
||||||
|
let curLine = null, curTop = null;
|
||||||
|
for (const w of pageWords) {
|
||||||
|
if (curLine && Math.abs(w.top - curTop) < w.h * 0.4) curLine.push(w);
|
||||||
|
else { curLine = [w]; curTop = w.top; lines.push(curLine); }
|
||||||
|
}
|
||||||
|
lines[0][0].para = true; // first line on the page always starts a paragraph
|
||||||
|
if (lines.length < 3) return;
|
||||||
|
const gaps = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) gaps.push(lines[i][0].top - lines[i - 1][0].top);
|
||||||
|
const sorted = [...gaps].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||||||
|
if (median <= 0) return;
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
if (lines[i][0].top - lines[i - 1][0].top > median * 1.35) lines[i][0].para = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Headline OCR: recover chapter titles baked into the PDF as images ───────
|
// ── Headline OCR: recover chapter titles baked into the PDF as images ───────
|
||||||
// pdf.js getTextContent() only ever returns real text glyphs, so a headline
|
// pdf.js getTextContent() only ever returns real text glyphs, so a headline
|
||||||
// drawn as a raster/vector graphic (common with stylised chapter title pages)
|
// drawn as a raster/vector graphic (common with stylised chapter title pages)
|
||||||
@ -533,6 +567,7 @@ async function readerExtractPdfText(loaded) {
|
|||||||
if (ocrWords.length) pageWords.unshift(...ocrWords);
|
if (ocrWords.length) pageWords.unshift(...ocrWords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
readerMarkParagraphBreaks(pageWords);
|
||||||
|
|
||||||
// Build + append this page's sentences and units
|
// Build + append this page's sentences and units
|
||||||
const pageBase = readerBuildSentences(pageWords);
|
const pageBase = readerBuildSentences(pageWords);
|
||||||
|
|||||||
@ -130,11 +130,12 @@ function statusActiveLlm() {
|
|||||||
|
|
||||||
function statusEngineChip(info) {
|
function statusEngineChip(info) {
|
||||||
const cls = info.ok ? 'ok' : 'bad';
|
const cls = info.ok ? 'ok' : 'bad';
|
||||||
return `<span class="status-engine ${cls}" title="${escHtml(info.title || '')}">
|
return `<button type="button" class="status-engine ${cls}" data-status-kind="${info.kind}" title="${escHtml(info.title || '')} — click to switch">
|
||||||
<span class="status-dot" aria-hidden="true"></span>
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
<span class="status-engine-label">${escHtml(info.label)}</span>
|
<span class="status-engine-label">${escHtml(info.label)}</span>
|
||||||
<span class="status-engine-value">${escHtml(info.value)}</span>
|
<span class="status-engine-value">${escHtml(info.value)}</span>
|
||||||
</span>`;
|
<span class="mdi mdi-chevron-up status-engine-caret"></span>
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusBar() {
|
function updateStatusBar() {
|
||||||
@ -145,6 +146,105 @@ function updateStatusBar() {
|
|||||||
engines.innerHTML = items.map(statusEngineChip).join('');
|
engines.innerHTML = items.map(statusEngineChip).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fly-up menus on the footer status chips — quickly switch the active
|
||||||
|
// LLM model / STT backend / TTS backend without hunting through Settings. ──
|
||||||
|
|
||||||
|
let _statusFlyup = null;
|
||||||
|
|
||||||
|
function _statusCloseFlyup() {
|
||||||
|
if (_statusFlyup) { _statusFlyup.remove(); _statusFlyup = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) {
|
||||||
|
_statusCloseFlyup();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'status-flyup';
|
||||||
|
if (kind) el.dataset.forKind = kind;
|
||||||
|
el.innerHTML = items.length
|
||||||
|
? items.map(it => `<button type="button" class="status-flyup-item${it.active ? ' is-active' : ''}" data-val="${escHtml(it.id)}">${it.active ? '<span class="mdi mdi-check"></span>' : ''}<span>${escHtml(it.label)}</span></button>`).join('')
|
||||||
|
: `<div class="status-flyup-empty">${escHtml(emptyLabel || 'Nothing available')}</div>`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px';
|
||||||
|
el.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
|
||||||
|
el.querySelectorAll('.status-flyup-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); });
|
||||||
|
});
|
||||||
|
_statusFlyup = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a chosen backend id to every matching <select> in the DOM (each
|
||||||
|
// screen keeps its own backend picker; this is the "quick switch everywhere"
|
||||||
|
// shortcut instead of hunting down each one individually).
|
||||||
|
function _statusApplyToSelects(ids, value) {
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = $(id);
|
||||||
|
if (el && [...el.options || []].some(o => o.value === value)) {
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _statusOpenLlmFlyup(anchor) {
|
||||||
|
const { url, model, apiKey } = statusLlmTarget();
|
||||||
|
if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; }
|
||||||
|
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
|
||||||
|
let models = [];
|
||||||
|
try {
|
||||||
|
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||||||
|
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
|
||||||
|
const data = await fetch(fetchUrl).then(r => r.json());
|
||||||
|
models = Array.isArray(data.models) ? data.models : [];
|
||||||
|
} catch (_) {}
|
||||||
|
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
|
||||||
|
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
|
||||||
|
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;
|
||||||
|
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {});
|
||||||
|
_statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked);
|
||||||
|
refreshStatusBarEngines({ force: true });
|
||||||
|
toast('LLM model set to ' + picked, 'success');
|
||||||
|
}, 'No models found at this endpoint', 'llm');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _statusOpenSttFlyup(anchor) {
|
||||||
|
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||||
|
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||||||
|
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
|
||||||
|
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||||||
|
})), (picked) => {
|
||||||
|
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
|
||||||
|
updateStatusBar();
|
||||||
|
toast('STT backend set to ' + picked, 'success');
|
||||||
|
}, 'No reachable STT backend', 'stt');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _statusOpenTtsFlyup(anchor) {
|
||||||
|
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||||||
|
const preferred = $('tts-backend-select')?.value || $('reader-backend-select')?.value || $('reh-backend-select')?.value || '';
|
||||||
|
_statusFlyupList(anchor, all.map(b => ({
|
||||||
|
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||||||
|
})), (picked) => {
|
||||||
|
_statusApplyToSelects(['tts-backend-select', 'reader-backend-select', 'reh-backend-select'], picked);
|
||||||
|
updateStatusBar();
|
||||||
|
toast('TTS backend set to ' + picked, 'success');
|
||||||
|
}, 'No reachable TTS backend', 'tts');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const chip = e.target.closest('.status-engine');
|
||||||
|
if (chip) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const kind = chip.dataset.statusKind;
|
||||||
|
if (_statusFlyup && _statusFlyup.dataset.forKind === kind) { _statusCloseFlyup(); return; }
|
||||||
|
if (kind === 'llm') _statusOpenLlmFlyup(chip);
|
||||||
|
else if (kind === 'stt') _statusOpenSttFlyup(chip);
|
||||||
|
else if (kind === 'tts') _statusOpenTtsFlyup(chip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.target.closest('.status-flyup')) _statusCloseFlyup();
|
||||||
|
});
|
||||||
|
|
||||||
async function refreshStatusBarEngines({ force = false } = {}) {
|
async function refreshStatusBarEngines({ force = false } = {}) {
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
const { url, model, apiKey } = statusLlmTarget();
|
const { url, model, apiKey } = statusLlmTarget();
|
||||||
@ -733,6 +833,14 @@ function workflowCrumbGo(key) {
|
|||||||
}
|
}
|
||||||
window.workflowCrumbGo = workflowCrumbGo;
|
window.workflowCrumbGo = workflowCrumbGo;
|
||||||
|
|
||||||
|
// Nearest reachable step from curIdx in the given direction (+1/-1), or null.
|
||||||
|
function _wfNeighbor(curIdx, dir) {
|
||||||
|
for (let i = curIdx + dir; i >= 0 && i < WF_STEPS.length; i += dir) {
|
||||||
|
if (WF_STEPS[i].enabled()) return WF_STEPS[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshWorkflowCrumbs(active) {
|
function refreshWorkflowCrumbs(active) {
|
||||||
if (active) _wfActive = active;
|
if (active) _wfActive = active;
|
||||||
const title = window.readerState?.title || window.rehState?.title || '';
|
const title = window.readerState?.title || window.rehState?.title || '';
|
||||||
@ -744,9 +852,12 @@ function refreshWorkflowCrumbs(active) {
|
|||||||
containers.forEach(el => {
|
containers.forEach(el => {
|
||||||
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
|
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
const parts = [];
|
|
||||||
if (title) parts.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
|
|
||||||
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
|
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
|
||||||
|
const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null;
|
||||||
|
const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null;
|
||||||
|
const parts = [];
|
||||||
|
parts.push(`<button type="button" class="wf-nav-btn wf-nav-prev" data-wf-nav="prev" title="${prevStep ? 'Back to ' + escHtml(prevStep.label) : 'No previous step'}"${prevStep ? '' : ' disabled'}><span class="mdi mdi-chevron-left"></span></button>`);
|
||||||
|
if (title) parts.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
|
||||||
WF_STEPS.forEach((st, i) => {
|
WF_STEPS.forEach((st, i) => {
|
||||||
const enabled = st.enabled();
|
const enabled = st.enabled();
|
||||||
const cur = _wfActive === st.key;
|
const cur = _wfActive === st.key;
|
||||||
@ -758,10 +869,15 @@ function refreshWorkflowCrumbs(active) {
|
|||||||
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
|
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
parts.push(`<button type="button" class="wf-nav-btn wf-nav-next" data-wf-nav="next" title="${nextStep ? 'On to ' + escHtml(nextStep.label) : 'No next step'}"${nextStep ? '' : ' disabled'}><span class="mdi mdi-chevron-right"></span></button>`);
|
||||||
el.innerHTML = parts.join('');
|
el.innerHTML = parts.join('');
|
||||||
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
|
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
|
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
|
||||||
});
|
});
|
||||||
|
const prevBtn = el.querySelector('.wf-nav-prev');
|
||||||
|
if (prevBtn && !prevBtn.disabled) prevBtn.addEventListener('click', () => workflowCrumbGo(prevStep.key));
|
||||||
|
const nextBtn = el.querySelector('.wf-nav-next');
|
||||||
|
if (nextBtn && !nextBtn.disabled) nextBtn.addEventListener('click', () => workflowCrumbGo(nextStep.key));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
|
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
|
||||||
|
|||||||
@ -751,6 +751,29 @@ audio { width: 100%; }
|
|||||||
font-size:11px; font-weight:700; letter-spacing:.9px; text-transform:uppercase;
|
font-size:11px; font-weight:700; letter-spacing:.9px; text-transform:uppercase;
|
||||||
color:var(--accent); margin-bottom:14px; display:flex; align-items:center; gap:7px;
|
color:var(--accent); margin-bottom:14px; display:flex; align-items:center; gap:7px;
|
||||||
}
|
}
|
||||||
|
/* Generation Prompts section: four fold-out "ready to copy" boxes */
|
||||||
|
.lcd-prompts-section .lcd-section-label { justify-content: space-between; }
|
||||||
|
.lcd-prompts-section .lcd-gen-prompts { margin-left: auto; text-transform: none; letter-spacing: 0; }
|
||||||
|
.lcd-prompt-box {
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; background: var(--surface);
|
||||||
|
margin-bottom: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.lcd-prompt-box summary {
|
||||||
|
cursor: pointer; padding: 9px 12px; font-size: 12.5px; font-weight: 700; color: var(--text);
|
||||||
|
list-style: none; display: flex; align-items: center; gap: 6px; user-select: none;
|
||||||
|
}
|
||||||
|
.lcd-prompt-box summary::before { content: '›'; font-size: 15px; color: var(--subtext); transition: transform .15s; }
|
||||||
|
.lcd-prompt-box[open] summary::before { transform: rotate(90deg); }
|
||||||
|
.lcd-prompt-box summary:hover { background: var(--panel); }
|
||||||
|
.lcd-prompt-empty { font-weight: 400; font-size: 11px; color: var(--subtext); font-style: italic; }
|
||||||
|
.lcd-prompt-body { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.lcd-prompt-text {
|
||||||
|
white-space: pre-wrap; font-size: 12.5px; line-height: 1.55; color: var(--text);
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 8px 10px; min-height: 40px; outline: none;
|
||||||
|
}
|
||||||
|
.lcd-prompt-text:focus { border-color: var(--accent); }
|
||||||
|
.lcd-prompt-copy { align-self: flex-end; }
|
||||||
.lcd-field { margin-bottom:16px; }
|
.lcd-field { margin-bottom:16px; }
|
||||||
.lcd-field:last-child { margin-bottom:0; }
|
.lcd-field:last-child { margin-bottom:0; }
|
||||||
.lcd-field-label {
|
.lcd-field-label {
|
||||||
@ -1041,7 +1064,11 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|||||||
.status-engine {
|
.status-engine {
|
||||||
min-width: 0; display: inline-flex; align-items: center; gap: 5px;
|
min-width: 0; display: inline-flex; align-items: center; gap: 5px;
|
||||||
white-space: nowrap; color: var(--subtext);
|
white-space: nowrap; color: var(--subtext);
|
||||||
|
background: none; border: none; padding: 2px 4px; margin: 0; border-radius: 4px;
|
||||||
|
font: inherit; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.status-engine:hover { background: var(--panel); color: var(--text); }
|
||||||
|
.status-engine-caret { font-size: 13px; opacity: .55; }
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto;
|
width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto;
|
||||||
background: var(--red); box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 14%, transparent);
|
background: var(--red); box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 14%, transparent);
|
||||||
@ -1050,6 +1077,24 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|||||||
.status-engine-label { font-weight: 800; color: var(--text); }
|
.status-engine-label { font-weight: 800; color: var(--text); }
|
||||||
.status-engine-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: min(34vw, 420px); }
|
.status-engine-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: min(34vw, 420px); }
|
||||||
.status-engine.bad .status-engine-value { color: var(--red); }
|
.status-engine.bad .status-engine-value { color: var(--red); }
|
||||||
|
/* Fly-up menu opened by clicking a footer engine chip — quick-switch the
|
||||||
|
active LLM model / STT backend / TTS backend without opening Settings. */
|
||||||
|
.status-flyup {
|
||||||
|
position: fixed; z-index: 2000; min-width: 200px; max-width: 320px;
|
||||||
|
max-height: 320px; overflow-y: auto;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 -8px 24px rgba(0,0,0,.18); padding: 4px;
|
||||||
|
}
|
||||||
|
.status-flyup-item {
|
||||||
|
display: flex; align-items: center; gap: 6px; width: 100%; text-align: left;
|
||||||
|
padding: 7px 10px; border: none; background: none; border-radius: 6px;
|
||||||
|
font-size: 12.5px; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.status-flyup-item:hover { background: var(--panel); }
|
||||||
|
.status-flyup-item.is-active { color: var(--accent); font-weight: 700; }
|
||||||
|
.status-flyup-item .mdi { font-size: 14px; flex-shrink: 0; }
|
||||||
|
.status-flyup-empty { padding: 10px 12px; font-size: 12px; color: var(--subtext); font-style: italic; }
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
#status-bar { align-items: flex-start; flex-direction: column; gap: 4px; }
|
#status-bar { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
#status-engines { margin-left: 0; width: 100%; justify-content: flex-start; flex-wrap: wrap; gap: 8px 14px; }
|
#status-engines { margin-left: 0; width: 100%; justify-content: flex-start; flex-wrap: wrap; gap: 8px 14px; }
|
||||||
@ -1623,6 +1668,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
direction; state lives wherever it was created (readerState/_audiobook/
|
direction; state lives wherever it was created (readerState/_audiobook/
|
||||||
rehState), so jumping around never destroys it. */
|
rehState), so jumping around never destroys it. */
|
||||||
.wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; }
|
.wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; }
|
||||||
|
.wf-nav-btn {
|
||||||
|
align-self: center; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border); background: var(--surface); color: var(--text);
|
||||||
|
cursor: pointer; font-size: 16px; margin: 0 6px;
|
||||||
|
}
|
||||||
|
.wf-nav-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: rgba(37,99,235,.08); }
|
||||||
|
.wf-nav-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
.wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
.wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.wf-step {
|
.wf-step {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||||
@ -5275,6 +5328,21 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.ab-cv-page-label:hover { color: var(--accent); }
|
.ab-cv-page-label:hover { color: var(--accent); }
|
||||||
.ab-cv-page-back { margin-left: auto; }
|
.ab-cv-page-back { margin-left: auto; }
|
||||||
.ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; }
|
.ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
/* Every word in the narration/dialogue text is individually clickable — the
|
||||||
|
basis for "click a name in the text to assign", including names the app
|
||||||
|
doesn't already know (drag across a multi-word one to select it all). */
|
||||||
|
.ab-name-hit { cursor: pointer; border-radius: 2px; }
|
||||||
|
.ab-name-hit:hover { background: rgba(37,99,235,.14); text-decoration: underline; text-decoration-style: dotted; }
|
||||||
|
.ab-cv-txt { cursor: pointer; }
|
||||||
|
/* Single reused overlay marking the word under the cursor (positioned in JS
|
||||||
|
from the caret range) — replaces per-word spans, which at book scale meant
|
||||||
|
~100k extra DOM nodes and froze the page. */
|
||||||
|
.ab-word-hover {
|
||||||
|
position: fixed; z-index: 5; pointer-events: none;
|
||||||
|
background: rgba(37,99,235,.16); border-radius: 3px;
|
||||||
|
outline: 1px dotted rgba(37,99,235,.55);
|
||||||
|
}
|
||||||
|
.ab-word-hover[hidden] { display: none; }
|
||||||
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
|
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
|
||||||
.ab-cv-side {
|
.ab-cv-side {
|
||||||
border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel);
|
border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel);
|
||||||
@ -5358,8 +5426,32 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
display:flex; align-items:center; justify-content:center;
|
display:flex; align-items:center; justify-content:center;
|
||||||
font-size:11px; font-weight:700; color:#fff;
|
font-size:11px; font-weight:700; color:#fff;
|
||||||
}
|
}
|
||||||
.ab-char-name { flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
.ab-char-name { flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width: 0; }
|
||||||
.ab-char-count { font-size:11px; color:var(--subtext); font-weight:700; flex-shrink:0; }
|
.ab-char-count { font-size:11px; color:var(--subtext); font-weight:700; flex-shrink:0; }
|
||||||
|
.ab-char-alias-btn {
|
||||||
|
flex-shrink: 0; width: 20px; height: 20px; border-radius: 4px; border: none;
|
||||||
|
background: none; color: var(--subtext); cursor: pointer; display: none;
|
||||||
|
align-items: center; justify-content: center; font-size: 13px;
|
||||||
|
}
|
||||||
|
.ab-char-item:hover .ab-char-alias-btn { display: inline-flex; }
|
||||||
|
.ab-char-alias-btn:hover { background: var(--panel); color: var(--accent); }
|
||||||
|
/* Sidebar's own collapse state hides the count/name anyway; keep the alias
|
||||||
|
button out of the way rather than fighting for the 44px column. */
|
||||||
|
.ab-cv-side.is-collapsed .ab-char-alias-btn { display: none !important; }
|
||||||
|
|
||||||
|
.ab-alias-popup {
|
||||||
|
position: fixed; z-index: 2002; width: 240px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,.35); padding: 10px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.ab-alias-popup-title { font-size: 12px; color: var(--subtext); }
|
||||||
|
.ab-alias-popup-title b { color: var(--text); }
|
||||||
|
.ab-alias-popup-inp {
|
||||||
|
width: 100%; padding: 6px 8px; font-size: 12.5px; border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; background: var(--bg, var(--surface)); color: var(--text); outline: none;
|
||||||
|
}
|
||||||
|
.ab-alias-popup-inp:focus { border-color: var(--accent); }
|
||||||
|
.ab-alias-popup-actions { display: flex; justify-content: flex-end; gap: 6px; }
|
||||||
/* Character detail panel inside the casting feed area */
|
/* Character detail panel inside the casting feed area */
|
||||||
.ab-char-detail-panel {
|
.ab-char-detail-panel {
|
||||||
display:flex; flex-direction:column; overflow-y:auto;
|
display:flex; flex-direction:column; overflow-y:auto;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user