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:
mARTin-B78 2026-07-04 12:48:00 +02:00
parent 637aeb3a2c
commit e02ca4d703
13 changed files with 952 additions and 110 deletions

View File

@ -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

View File

@ -1 +1 @@
1.12.86 1.12.90

View File

@ -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 "

View File

@ -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"'},
) )

View File

@ -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>

View File

@ -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 () => {

View File

@ -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;

View File

@ -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'],
]; ];

View File

@ -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];

View File

@ -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');

View File

@ -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);

View File

@ -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;

View File

@ -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;