- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
650 lines
23 KiB
HTML
650 lines
23 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN" data-color-theme="green">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>预览 · 气泡下的模型卡片</title>
|
||
<style>
|
||
:root {
|
||
--st-bg: #dce5d9;
|
||
--st-surface: #f4f9f2;
|
||
--st-border: #c0cfbb;
|
||
--st-border-strong: #a8bca2;
|
||
--st-text: #2b3330;
|
||
--st-muted: #65716c;
|
||
--st-faint: #7a8580;
|
||
--st-accent: #6f8e6c;
|
||
--st-accent-soft: rgba(125, 155, 122, 0.15);
|
||
--st-danger: #9a4038;
|
||
--st-font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
height: 100%;
|
||
background: var(--st-bg);
|
||
color: var(--st-text);
|
||
font-family: var(--st-font-ui);
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.room {
|
||
height: 100%;
|
||
max-width: 920px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
background: color-mix(in srgb, var(--st-bg) 72%, var(--st-surface));
|
||
border-left: 1px solid var(--st-border);
|
||
border-right: 1px solid var(--st-border);
|
||
}
|
||
|
||
.preview-bar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 8px 16px;
|
||
border-bottom: 1px solid var(--st-border);
|
||
background: var(--st-surface);
|
||
font-size: 12px;
|
||
color: var(--st-muted);
|
||
}
|
||
|
||
.preview-bar strong {
|
||
font-weight: 650;
|
||
color: var(--st-text);
|
||
}
|
||
|
||
.transcript {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 20px 16px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 22px;
|
||
}
|
||
|
||
.turn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.user-row {
|
||
display: flex;
|
||
}
|
||
|
||
.bubble {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid color-mix(in srgb, var(--st-accent) 28%, var(--st-border));
|
||
background: color-mix(in srgb, var(--st-accent-soft) 70%, var(--st-surface));
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
line-height: 1.5;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.bubble-time {
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: var(--st-faint);
|
||
text-align: right;
|
||
}
|
||
|
||
.strip-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
min-height: 28px;
|
||
}
|
||
|
||
.strip-hint {
|
||
font-size: 11px;
|
||
color: var(--st-faint);
|
||
}
|
||
|
||
.strip-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.idx {
|
||
min-width: 3.2rem;
|
||
text-align: center;
|
||
font-size: 11px;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--st-muted);
|
||
}
|
||
|
||
.nav-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid transparent;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: var(--st-muted);
|
||
font: inherit;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.nav-btn:hover,
|
||
.nav-btn:focus-visible {
|
||
color: var(--st-accent);
|
||
background: var(--st-accent-soft);
|
||
outline: none;
|
||
}
|
||
|
||
.nav-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
background: transparent;
|
||
color: var(--st-faint);
|
||
}
|
||
|
||
.track {
|
||
--card-gap: 10px;
|
||
--card-peek: 36px;
|
||
--card-min-h: 120px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--card-gap);
|
||
overflow-x: auto;
|
||
scroll-snap-type: x mandatory;
|
||
scroll-padding-inline: 0;
|
||
padding-bottom: 2px;
|
||
cursor: grab;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.track::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.track.is-dragging {
|
||
cursor: grabbing;
|
||
scroll-snap-type: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.card {
|
||
flex: 0 0 calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||
width: calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||
scroll-snap-align: start;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: var(--card-min-h);
|
||
border: 1px solid var(--st-border);
|
||
border-radius: 8px;
|
||
background: var(--st-surface);
|
||
}
|
||
|
||
.track[data-count="1"] .card {
|
||
flex-basis: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
.card.is-collapsed .card-text {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card.is-selected {
|
||
border-color: var(--st-accent);
|
||
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
||
}
|
||
|
||
.card.is-error {
|
||
border-color: color-mix(in srgb, var(--st-danger) 45%, var(--st-border));
|
||
}
|
||
|
||
.card-head {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--st-border);
|
||
}
|
||
|
||
.card-titles { min-width: 0; }
|
||
|
||
.card-name {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-model {
|
||
display: block;
|
||
margin-top: 1px;
|
||
font-size: 11px;
|
||
color: var(--st-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-body {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px;
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.card-body.is-error { color: var(--st-danger); }
|
||
.card-body.is-pending { color: var(--st-faint); }
|
||
|
||
.card-foot {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 0 10px 8px;
|
||
}
|
||
|
||
.card-foot:not(:has(button:not([hidden]))) {
|
||
display: none;
|
||
}
|
||
|
||
.expand-btn {
|
||
margin-right: auto;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--st-muted);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
padding: 4px 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.expand-btn:hover,
|
||
.expand-btn:focus-visible {
|
||
color: var(--st-accent);
|
||
outline: none;
|
||
}
|
||
|
||
.select-btn,
|
||
.remove-btn {
|
||
border: 1px solid var(--st-border-strong);
|
||
border-radius: 6px;
|
||
background: var(--st-surface);
|
||
color: var(--st-text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
padding: 4px 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.select-btn:hover,
|
||
.remove-btn:hover,
|
||
.select-btn:focus-visible,
|
||
.remove-btn:focus-visible {
|
||
border-color: var(--st-accent);
|
||
background: var(--st-accent-soft);
|
||
outline: none;
|
||
}
|
||
|
||
.select-btn.is-on {
|
||
border-color: var(--st-accent);
|
||
background: var(--st-accent-soft);
|
||
font-weight: 650;
|
||
}
|
||
|
||
.composer {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
padding: 10px 16px 14px;
|
||
border-top: 1px solid var(--st-border);
|
||
background: var(--st-surface);
|
||
}
|
||
|
||
.composer textarea {
|
||
flex: 1;
|
||
min-height: 38px;
|
||
max-height: 120px;
|
||
resize: none;
|
||
border: 1px solid var(--st-border-strong);
|
||
border-radius: 8px;
|
||
background: var(--st-bg);
|
||
color: var(--st-faint);
|
||
padding: 8px 10px;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.send {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: var(--st-accent);
|
||
color: #f4f9f2;
|
||
font: inherit;
|
||
cursor: default;
|
||
opacity: 0.45;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="room">
|
||
<div class="preview-bar">
|
||
<span><strong>临时会话</strong> · 预览,回复都是字符串</span>
|
||
<span>同一条消息只出现一次,卡片可左右拖</span>
|
||
</div>
|
||
<div class="transcript" id="transcript"></div>
|
||
<footer class="composer">
|
||
<textarea rows="1" readonly placeholder="预览不发送。正式页仍是底部这一条输入。"></textarea>
|
||
<button type="button" class="send" disabled aria-label="发送">↑</button>
|
||
</footer>
|
||
</div>
|
||
<script>
|
||
const windows = [
|
||
{ id: "w1", name: "claude-haiku-5.5", model: "anthropic/claude-haiku-5.5" },
|
||
{ id: "w2", name: "gemini-3.8-flash", model: "google/gemini-3.8-flash" },
|
||
{ id: "w3", name: "gpt-5.4-mini", model: "openai/gpt-5.4-mini" },
|
||
{ id: "w4", name: "deepseek-v4", model: "deepseek/deepseek-v4" },
|
||
{ id: "w5", name: "claude-sonnet-5", model: "anthropic/claude-sonnet-5" },
|
||
];
|
||
|
||
const turns = [
|
||
{
|
||
id: "t1",
|
||
time: "21:06",
|
||
user: "开篇别写天气。林晚推门进来,屋里已经有人。收短,留下一句她没问出口的话。",
|
||
selected: "w2",
|
||
replies: {
|
||
w1: "门轴响了一下。林晚进门,外套还挂在臂上,肩上的水珠顺着袖口往下掉。桌边坐着的人抬了抬眼,又低下去,手指停在杯沿上。她把钥匙放进碟子,金属碰了一声。那句「你怎么在这」到了舌尖,她用舌头顶回去,只伸手去摘围巾。围巾结打得紧,她解了两次才松开,穗子扫过桌面,拂开一张折过的纸。纸角朝上,她看见自己的名字,没有去拿。",
|
||
w2: "林晚推门时灯已经亮着。那人坐在桌边,面前一只空杯子,杯底还沾着一圈茶渍。她把门带上,声音比想的轻,锁舌却还是响了。钥匙落进碟子里。她站在门边,鞋跟没有再往前。话到了嘴边,她侧过身去烧水,壶盖磕在灶沿上,那句话就停在水壶的响声里。水开之前谁都没说话。她背对着那人,手指扣着灶台的边,扣得指节发白。",
|
||
w3: "林晚推开门。屋里的灯亮着,桌边已经坐了一个人,面前是一只空杯子。她把钥匙放进门口的碟子,金属碰了一声,手指在碟沿上停了一停。那句问话到了嘴边,没有出口。她解下围巾,搭到椅背上,手还按着木茬,人站在椅子侧面,没有往桌边走。那人把杯子转了半圈。林晚看着杯底画出的水印,终于把另一只手也放上椅背,围巾的穗子从指缝里滑下去。",
|
||
w4: "门开着一条缝,光从里面漏出来。林晚侧身进去,看见那人的背影对着窗。她张了张嘴,喉咙里那句话转了一圈,最后只变成解围巾的动作。围巾搭上椅背,她的手还按着,没有拿开。椅背是凉的。那人没有回头,只把空杯子转了半圈。林晚看着那只杯子,又看着自己按在椅背上的手,终于把另一只手也放上去,像是要说什么,到底只把围巾的穗子理齐。",
|
||
w5: { error: "上游超时,这条没有回复。" },
|
||
},
|
||
},
|
||
{
|
||
id: "t2",
|
||
time: "21:11",
|
||
user: "她没问出口的那句,换成一个动作。别解释她在想什么。",
|
||
selected: null,
|
||
replies: {
|
||
w1: { pending: true },
|
||
w2: "她把那只空杯子转了半圈,又转回去,杯底在桌面上刮出一声很轻的响。林晚的手还按在椅背上,围巾的穗子从指缝里漏下去。她没有坐。那人这才抬眼,目光停在她的手上,又移开。林晚抽回手,把围巾从椅背上拿起来,重新搭好,动作慢得像在把一句已经到了嘴边的话按回去。",
|
||
w3: "林晚拉开椅子,椅腿在地板上拖出短短一声。她没有坐下去。手离开椅背,转到水槽边,拧开水龙头。水声起来以后,那人把空杯子往她这边推了推,杯底刮过桌面。她接过杯子,冲了一下,倒扣在沥水架上。水还在流。她伸手去关,指节在龙头上停住,又拧紧,全程没有回头看他。围巾还搭在椅背上,穗子垂到座位上。",
|
||
w4: "她把围巾搭上椅背,手还按着,没有拿开。指腹压住一块被磨毛的木茬。那人的杯子停在手边,她用空着的那只手把杯子转了半圈,又转回去,水渍在桌上画出一个不完整的圆。圆没有合上,她的手也没有离开椅背。",
|
||
w5: { error: "上游超时,这条没有回复。" },
|
||
},
|
||
},
|
||
];
|
||
|
||
const alive = new Set(windows.map((w) => w.id));
|
||
const expanded = new Set();
|
||
const transcript = document.getElementById("transcript");
|
||
const MIN_CARD_H = 120;
|
||
|
||
/** 至少两张都能撑到的最大高度:两张即最短,更多即次短。 */
|
||
function sharedReachHeight(heights) {
|
||
if (!heights.length) return MIN_CARD_H;
|
||
const sorted = [...heights].sort((a, b) => a - b);
|
||
const index = Math.min(1, Math.max(0, sorted.length - 2));
|
||
return Math.max(MIN_CARD_H, sorted[index]);
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
function bodyOf(reply) {
|
||
if (!reply) return { cls: "is-pending", text: "本轮无回复" };
|
||
if (reply.error) return { cls: "is-error", text: reply.error };
|
||
if (reply.pending) return { cls: "is-pending", text: "正在回复…" };
|
||
return { cls: "", text: reply };
|
||
}
|
||
|
||
function render() {
|
||
const list = windows.filter((w) => alive.has(w.id));
|
||
transcript.innerHTML = turns
|
||
.map((turn) => {
|
||
const kept = turn.selected && list.some((w) => w.id === turn.selected && !bodyOf(turn.replies[w.id]).cls) ? turn.selected : null;
|
||
const shown = kept ? list.filter((w) => w.id === kept) : list;
|
||
const cards = shown
|
||
.map((w) => {
|
||
const body = bodyOf(turn.replies[w.id]);
|
||
const selected = turn.selected === w.id && !body.cls;
|
||
const keep =
|
||
list.length > 1 && body.cls !== "is-error" && body.cls !== "is-pending"
|
||
? `<button type="button" class="select-btn ${selected ? "is-on" : ""}" data-action="keep" data-turn="${turn.id}" data-window="${w.id}">${selected ? "已保留 · 取消" : "保留此回复"}</button>`
|
||
: "";
|
||
const remove =
|
||
list.length > 1
|
||
? `<button type="button" class="remove-btn" data-action="remove" data-window="${w.id}">移除</button>`
|
||
: "";
|
||
return `<article class="card ${selected ? "is-selected" : ""} ${body.cls === "is-error" ? "is-error" : ""}" data-window="${w.id}">
|
||
<header class="card-head">
|
||
<div class="card-titles">
|
||
<span class="card-name">${esc(w.name)}</span>
|
||
<span class="card-model">${esc(w.model)}</span>
|
||
</div>
|
||
${remove}
|
||
</header>
|
||
<div class="card-body ${body.cls}" data-empty="${body.cls ? "1" : "0"}"><div class="card-text">${esc(body.text)}</div></div>
|
||
<div class="card-foot">
|
||
<button type="button" class="expand-btn" data-action="expand" data-turn="${turn.id}" data-window="${w.id}" hidden>展开</button>
|
||
${keep}
|
||
</div>
|
||
</article>`;
|
||
})
|
||
.join("");
|
||
const stripBar = kept
|
||
? ""
|
||
: `<div class="strip-bar">
|
||
<span class="strip-hint">左右拖动</span>
|
||
<div class="strip-nav">
|
||
<button type="button" class="nav-btn" data-action="prev" data-turn="${turn.id}" aria-label="上一张">‹</button>
|
||
<span class="idx" data-idx="${turn.id}">1 / ${list.length}</span>
|
||
<button type="button" class="nav-btn" data-action="next" data-turn="${turn.id}" aria-label="下一张">›</button>
|
||
</div>
|
||
</div>`;
|
||
return `<section class="turn" data-turn="${turn.id}">
|
||
<div class="user-row">
|
||
<article class="bubble">
|
||
<div>${esc(turn.user)}</div>
|
||
<div class="bubble-time">${esc(turn.time)}</div>
|
||
</article>
|
||
</div>
|
||
${stripBar}
|
||
<div class="track" data-turn="${turn.id}" data-count="${shown.length}" tabindex="0">${cards}</div>
|
||
</section>`;
|
||
})
|
||
.join("");
|
||
for (const track of transcript.querySelectorAll(".track")) bindTrack(track);
|
||
collapseTracks();
|
||
}
|
||
|
||
function collapseTracks() {
|
||
for (const track of transcript.querySelectorAll(".track")) {
|
||
const cards = [...track.querySelectorAll(".card")];
|
||
if (cards.length < 2) {
|
||
for (const card of cards) card.style.height = "auto";
|
||
continue;
|
||
}
|
||
const measured = cards.map((card) => {
|
||
card.style.height = "auto";
|
||
const empty = card.querySelector(".card-body")?.dataset.empty === "1";
|
||
return { card, h: card.offsetHeight, empty };
|
||
});
|
||
const substantive = measured.filter((item) => !item.empty).map((item) => item.h);
|
||
const target = sharedReachHeight(substantive);
|
||
for (const { card, h } of measured) {
|
||
const key = `${track.dataset.turn}:${card.dataset.window}`;
|
||
const btn = card.querySelector("[data-action='expand']");
|
||
const open = expanded.has(key);
|
||
if (open) {
|
||
card.style.height = "auto";
|
||
card.classList.remove("is-collapsed");
|
||
if (btn) {
|
||
btn.hidden = false;
|
||
btn.textContent = "收起";
|
||
}
|
||
continue;
|
||
}
|
||
card.style.height = `${target}px`;
|
||
card.classList.add("is-collapsed");
|
||
if (btn) {
|
||
const overflow = h > target + 1;
|
||
btn.hidden = !overflow;
|
||
btn.textContent = "展开";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function cardStep(track) {
|
||
const card = track.querySelector(".card");
|
||
if (!card) return track.clientWidth;
|
||
const gap = 10;
|
||
return card.getBoundingClientRect().width + gap;
|
||
}
|
||
|
||
function updateIdx(track) {
|
||
const cards = [...track.querySelectorAll(".card")];
|
||
const turnId = track.dataset.turn;
|
||
const idx = transcript.querySelector(`[data-idx="${turnId}"]`);
|
||
if (!idx || !cards.length) return;
|
||
const step = cardStep(track);
|
||
const start = Math.min(cards.length - 1, Math.max(0, Math.round(track.scrollLeft / step)));
|
||
const end = Math.min(cards.length, start + 2);
|
||
idx.textContent = end > start + 1 ? `${start + 1}–${end} / ${cards.length}` : `${start + 1} / ${cards.length}`;
|
||
const prev = transcript.querySelector(`[data-action="prev"][data-turn="${turnId}"]`);
|
||
const next = transcript.querySelector(`[data-action="next"][data-turn="${turnId}"]`);
|
||
const lastPage = Math.max(0, cards.length - 2);
|
||
if (prev) prev.disabled = start <= 0;
|
||
if (next) next.disabled = start >= lastPage;
|
||
}
|
||
|
||
function scrollToIndex(track, index) {
|
||
const cards = [...track.querySelectorAll(".card")];
|
||
const card = cards[index];
|
||
if (!card) return;
|
||
track.scrollTo({ left: card.offsetLeft - track.offsetLeft, behavior: "smooth" });
|
||
}
|
||
|
||
function bindTrack(track) {
|
||
let dragging = false;
|
||
let axis = null;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let startScroll = 0;
|
||
|
||
track.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || e.target.closest("button")) return;
|
||
dragging = true;
|
||
axis = null;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startScroll = track.scrollLeft;
|
||
});
|
||
|
||
track.addEventListener("pointermove", (e) => {
|
||
if (!dragging) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
if (!axis) {
|
||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||
axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||
if (axis === "x") {
|
||
track.classList.add("is-dragging");
|
||
track.setPointerCapture(e.pointerId);
|
||
}
|
||
}
|
||
if (axis === "x") {
|
||
track.scrollLeft = startScroll - dx;
|
||
}
|
||
});
|
||
|
||
const end = () => {
|
||
dragging = false;
|
||
axis = null;
|
||
track.classList.remove("is-dragging");
|
||
};
|
||
track.addEventListener("pointerup", end);
|
||
track.addEventListener("pointercancel", end);
|
||
track.addEventListener("scroll", () => updateIdx(track), { passive: true });
|
||
updateIdx(track);
|
||
}
|
||
|
||
transcript.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button");
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
if (action === "expand") {
|
||
const key = `${btn.dataset.turn}:${btn.dataset.window}`;
|
||
if (expanded.has(key)) expanded.delete(key);
|
||
else expanded.add(key);
|
||
collapseTracks();
|
||
return;
|
||
}
|
||
if (action === "remove") {
|
||
alive.delete(btn.dataset.window);
|
||
for (const turn of turns) {
|
||
if (turn.selected === btn.dataset.window) turn.selected = null;
|
||
}
|
||
for (const key of [...expanded]) {
|
||
if (key.endsWith(`:${btn.dataset.window}`)) expanded.delete(key);
|
||
}
|
||
render();
|
||
return;
|
||
}
|
||
if (action === "keep") {
|
||
const turn = turns.find((t) => t.id === btn.dataset.turn);
|
||
if (!turn) return;
|
||
turn.selected = turn.selected === btn.dataset.window ? null : btn.dataset.window;
|
||
render();
|
||
return;
|
||
}
|
||
if (action === "prev" || action === "next") {
|
||
const track = transcript.querySelector(`.track[data-turn="${btn.dataset.turn}"]`);
|
||
if (!track) return;
|
||
const step = cardStep(track);
|
||
const i = Math.round(track.scrollLeft / step);
|
||
const lastPage = Math.max(0, track.querySelectorAll(".card").length - 2);
|
||
const nextIndex = action === "next" ? Math.min(lastPage, i + 1) : Math.max(0, i - 1);
|
||
scrollToIndex(track, nextIndex);
|
||
}
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|