viewer-controls.js•18.6 kB
// Viewer Controls: floating panel for line numbering and auto refresh/scroll
(function () {
const detailEl = document.querySelector('.workflow-detail');
if (!detailEl) return;
const workflowId = detailEl.dataset.workflowId;
const container = document.getElementById('scratchpads-container');
if (!container) return;
const PREFS = {
lineOrder: `viewer:${workflowId}:lineOrder`, // 'asc' | 'desc'
autoRefresh: `viewer:${workflowId}:autoRefresh`, // '1' | '0'
autoScroll: `viewer:${workflowId}:autoScroll`, // '1' | '0'
updateMode: `viewer:${workflowId}:updateMode`, // 'auto' | 'sse' | 'json' | 'html'
};
// Helpers
const getPref = (k, def) => window.localStorage.getItem(k) ?? def;
const setPref = (k, v) => window.localStorage.setItem(k, v);
// Focused scratchpad detection via URL (?sp= or hash #sp=)
function getFocusedScratchpadId() {
const u = new URL(window.location.href);
const fromQuery = u.searchParams.get('sp');
if (fromQuery) return fromQuery;
const hash = u.hash || '';
if (hash.startsWith('#')) {
try {
const p = new URLSearchParams(hash.slice(1));
return p.get('sp');
} catch {}
}
return null;
}
function createControls() {
const panel = document.createElement('div');
panel.className = 'floating-controls';
panel.innerHTML = `
<button id="toggle-view" class="toggle-btn" type="button" aria-pressed="false">視圖: 渲染</button>
<button id="toggle-line" class="toggle-btn" type="button" aria-pressed="false">行號: 正序</button>
<button id="toggle-refresh" class="toggle-btn" type="button" aria-pressed="false" title="每隔數秒重新載入內容">自動刷新: 關</button>
<button id="toggle-scroll" class="toggle-btn" type="button" aria-pressed="false" title="內容更新後自動捲到底部">自動捲動: 關</button>
<button id="toggle-focus" class="toggle-btn" type="button" aria-pressed="false" title="切換是否聚焦單一 scratchpad">聚焦: 關</button>
`;
// 將面板插入到 scratchpads 區塊,貼齊渲染容器
const host = document.querySelector('.scratchpads-section') || detailEl;
host.insertBefore(panel, host.firstChild);
function positionPanel() {
const rect = host.getBoundingClientRect();
const spacing = 16; // px
const panelWidth = panel.offsetWidth || 300;
const desiredLeft = Math.round(rect.right + spacing);
const fitsRight = desiredLeft + panelWidth <= window.innerWidth - 8;
if (fitsRight) {
// 固定在視窗中,貼齊容器右外側
panel.style.position = 'fixed';
panel.style.left = desiredLeft + 'px';
// 讓面板大致與容器頂對齊,並在視窗內夾取
const desiredTop = Math.round(
Math.max(16, Math.min(rect.top, window.innerHeight - (panel.offsetHeight || 0) - 16))
);
panel.style.top = desiredTop + 'px';
panel.style.right = '';
panel.style.marginLeft = '';
} else {
// 空間不足,退回容器內側右上角(sticky 效果)
panel.style.position = 'sticky';
panel.style.left = '';
// 當退回容器內側時,調整 top 位置避免與 sticky header 重疊
panel.style.top = '3.7rem';
panel.style.right = '';
panel.style.marginLeft = 'auto';
}
}
// 初始定位 + 事件綁定
positionPanel();
window.addEventListener('scroll', positionPanel, { passive: true });
window.addEventListener('resize', positionPanel);
// Restore state
const viewBtn = panel.querySelector('#toggle-view');
const lineBtn = panel.querySelector('#toggle-line');
const refreshBtn = panel.querySelector('#toggle-refresh');
const scrollBtn = panel.querySelector('#toggle-scroll');
const focusBtn = panel.querySelector('#toggle-focus');
// Default: rendered view
const initialView = getPref(`viewer:${workflowId}:view`, 'rendered');
const initialLine = getPref(PREFS.lineOrder, 'asc');
const initialRefresh = getPref(PREFS.autoRefresh, '0') === '1';
const initialScroll = getPref(PREFS.autoScroll, '0') === '1';
function updateViewBtnLabel(mode) {
viewBtn.textContent = `視圖: ${mode === 'raw' ? '原始' : '渲染'}`;
viewBtn.setAttribute('aria-pressed', mode === 'raw' ? 'true' : 'false');
}
function updateLineBtnLabel(order) {
lineBtn.textContent = `行號: ${order === 'desc' ? '倒序' : '正序'}`;
lineBtn.setAttribute('aria-pressed', order === 'desc' ? 'true' : 'false');
}
function updateToggle(btn, on) {
btn.textContent = `${btn.id === 'toggle-refresh' ? '自動刷新' : '自動捲動'}: ${on ? '開' : '關'}`;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
updateViewBtnLabel(initialView);
updateLineBtnLabel(initialLine);
updateToggle(refreshBtn, initialRefresh);
updateToggle(scrollBtn, initialScroll);
updateLineToggleVisibility(initialView);
updateFocusBtn();
applyViewMode(initialView);
viewBtn.addEventListener('click', () => {
const current = getPref(`viewer:${workflowId}:view`, 'rendered');
const next = current === 'raw' ? 'rendered' : 'raw';
setPref(`viewer:${workflowId}:view`, next);
updateViewBtnLabel(next);
applyViewMode(next);
renderLineNumbersForAll();
updateLineToggleVisibility(next);
});
lineBtn.addEventListener('click', () => {
const current = getPref(PREFS.lineOrder, 'asc');
const next = current === 'desc' ? 'asc' : 'desc';
setPref(PREFS.lineOrder, next);
updateLineBtnLabel(next);
renderLineNumbersForAll();
});
refreshBtn.addEventListener('click', () => {
const cur = getPref(PREFS.autoRefresh, '0') === '1';
const next = !cur;
setPref(PREFS.autoRefresh, next ? '1' : '0');
updateToggle(refreshBtn, next);
});
scrollBtn.addEventListener('click', () => {
const cur = getPref(PREFS.autoScroll, '0') === '1';
const next = !cur;
setPref(PREFS.autoScroll, next ? '1' : '0');
updateToggle(scrollBtn, next);
});
// 聚焦切換:若未聚焦則預設聚焦至最後一個 scratchpad;若已聚焦則退出並回 workflow 級
focusBtn.addEventListener('click', () => {
const sid = getFocusedScratchpadId();
const url = new URL(window.location.href);
if (sid) {
// 退出聚焦:移除 ?sp / #sp
url.searchParams.delete('sp');
if (url.hash && url.hash.includes('sp=')) {
try {
const hs = new URLSearchParams(url.hash.slice(1));
hs.delete('sp');
url.hash = hs.toString() ? `#${hs.toString()}` : '';
} catch {
url.hash = '';
}
}
} else {
// 進入聚焦:鎖定最後一個 scratchpad
const target = getLastScratchpadId();
if (!target) return;
url.searchParams.set('sp', target);
}
// 以重新載入啟動對應的更新流(簡化狀態切換與清理)
window.location.href = url.toString();
});
}
function updateLineToggleVisibility(mode) {
const btn = document.querySelector('.floating-controls #toggle-line');
if (!btn) return;
btn.style.display = mode === 'raw' ? 'inline-flex' : 'none';
}
function renderLineNumbersForAll() {
const order = getPref(PREFS.lineOrder, 'asc');
const mode = getPref(`viewer:${workflowId}:view`, 'rendered');
const items = container.querySelectorAll('.markdown-with-lines');
// 在渲染視圖下不顯示行號,並清空 gutter
if (mode !== 'raw') {
items.forEach((wrap) => {
const gutter = wrap.querySelector('.line-gutter');
if (gutter) gutter.innerHTML = '';
});
return;
}
items.forEach((wrap) => {
const gutter = wrap.querySelector('.line-gutter');
if (!gutter) return;
const lineCount = parseInt(wrap.getAttribute('data-line-count') || '0', 10);
if (!Number.isFinite(lineCount) || lineCount <= 0) {
gutter.innerHTML = '';
return;
}
// Efficient bulk render
const frag = document.createDocumentFragment();
if (order === 'desc') {
for (let i = lineCount; i >= 1; i--) {
const s = document.createElement('span');
s.className = 'line-num';
s.textContent = String(i);
frag.appendChild(s);
}
} else {
for (let i = 1; i <= lineCount; i++) {
const s = document.createElement('span');
s.className = 'line-num';
s.textContent = String(i);
frag.appendChild(s);
}
}
gutter.replaceChildren(frag);
});
}
function decodeRaw(b64) {
try {
return decodeURIComponent(escape(window.atob(b64)));
} catch {
// Fallback for UTF-8 decoding in older browsers
return atob(b64);
}
}
function applyViewMode(mode) {
document.body.classList.toggle('view-raw', mode === 'raw');
document.body.classList.toggle('view-rendered', mode !== 'raw');
const items = container.querySelectorAll('.markdown-with-lines');
items.forEach((wrap) => {
let rawEl = wrap.querySelector('.raw-view');
if (mode === 'raw') {
if (!rawEl) {
const b64 = wrap.getAttribute('data-raw-b64') || '';
const pre = document.createElement('pre');
pre.className = 'raw-view';
pre.textContent = b64 ? decodeRaw(b64) : '';
wrap.appendChild(pre);
}
}
});
}
function replaceScratchpadsWithHTML(html) {
// 解析回傳 HTML,若包含外層 #scratchpads-container,改為 child-level 置換避免巢狀
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContainer = doc.getElementById('scratchpads-container');
if (newContainer) {
container.replaceChildren(...Array.from(newContainer.childNodes));
} else {
// 非完整文件情況,使用臨時容器取子節點
const tmp = document.createElement('div');
tmp.innerHTML = html;
const inner = tmp.querySelector('#scratchpads-container');
if (inner) {
container.replaceChildren(...Array.from(inner.childNodes));
} else {
// 後退保險:直接覆寫(理論上不會用到)
container.innerHTML = html;
}
}
} catch {
container.innerHTML = html;
}
renderLineNumbersForAll();
const currentView = getPref(`viewer:${workflowId}:view`, 'rendered');
applyViewMode(currentView);
updateLineToggleVisibility(currentView);
if (getPref(PREFS.autoScroll, '0') === '1') {
container.scrollTop = container.scrollHeight;
window.scrollTo({ top: document.body.scrollHeight });
}
}
async function fetchScratchpadsFragment() {
const resp = await fetch(`/api/workflow/${workflowId}/scratchpads`, { cache: 'no-store' });
if (!resp.ok) return null;
return await resp.text();
}
async function fetchScratchpadItemHtml(sid) {
const resp = await fetch(`/api/scratchpad/${sid}/html`, { cache: 'no-store' });
if (!resp.ok) return null;
return await resp.text();
}
async function jsonPollLoop() {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let lastUpdated = Number(detailEl.dataset.updatedAt || 0);
while (true) {
const enabled = getPref(PREFS.autoRefresh, '0') === '1';
if (!enabled) {
await sleep(5000);
continue;
}
try {
const url = `/api/workflow/${workflowId}/summary?since=${lastUpdated}`;
const res = await fetch(url, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (res.status === 304) {
// no change
} else if (res.ok) {
const data = await res.json();
if (data.changed) {
const frag = await fetchScratchpadsFragment();
if (frag) replaceScratchpadsWithHTML(frag);
lastUpdated = data.updated_at;
}
}
} catch {}
await sleep(5000);
}
}
function startSSE() {
let es;
try {
es = new EventSource(`/sse/workflow/${workflowId}`);
} catch {
return false;
}
let active = true;
const onUpdate = async (evt) => {
if (!active) return;
const frag = await fetchScratchpadsFragment();
if (frag) replaceScratchpadsWithHTML(frag);
};
es.addEventListener('update', onUpdate);
es.onerror = () => {
// 停用 SSE,回退 JSON
try {
es.close();
} catch {}
active = false;
jsonPollLoop();
};
return true;
}
// 取得最後(最新)一個 scratchpad 的 ID
function getLastScratchpadId() {
const items = container.querySelectorAll('.scratchpad-item[data-scratchpad-id]');
if (!items.length) return null;
return items[items.length - 1].getAttribute('data-scratchpad-id');
}
// 更新聚焦按鈕狀態與文字
function updateFocusBtn() {
const btn = document.querySelector('.floating-controls #toggle-focus');
if (!btn) return;
const sid = getFocusedScratchpadId();
const enabled = !!sid;
btn.textContent = `聚焦: ${enabled ? '開(單一 scratchpad)' : '關'}`;
btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
btn.disabled = !enabled && !getLastScratchpadId();
}
// Replace a single scratchpad item by id
function replaceSingleScratchpadDom(sid, html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
const newItem = tmp.querySelector('.scratchpad-item');
if (!newItem) return;
const oldItem = container.querySelector(`.scratchpad-item[data-scratchpad-id="${sid}"]`);
if (!oldItem) return;
oldItem.replaceWith(newItem);
renderLineNumbersForAll();
const currentView = getPref(`viewer:${workflowId}:view`, 'rendered');
applyViewMode(currentView);
updateLineToggleVisibility(currentView);
if (getPref(PREFS.autoScroll, '0') === '1') {
newItem.scrollIntoView({ block: 'end' });
}
}
function startScratchpadSSE(sid) {
let es;
try {
es = new EventSource(`/sse/scratchpad/${sid}`);
} catch {
return false;
}
let active = true;
es.addEventListener('update', async () => {
if (!active) return;
const html = await fetchScratchpadItemHtml(sid);
if (html) replaceSingleScratchpadDom(sid, html);
});
es.onerror = () => {
try {
es.close();
} catch {}
active = false;
scratchpadJsonPoll(sid);
};
return true;
}
async function scratchpadJsonPoll(sid) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let last = 0;
try {
// seed with current dom time if available (not tracked), so start with 0
} catch {}
while (true) {
const enabled = getPref(PREFS.autoRefresh, '0') === '1';
if (!enabled) {
await sleep(5000);
continue;
}
try {
const res = await fetch(`/api/scratchpad/${sid}/summary?since=${last}`, {
cache: 'no-store',
});
if (res.status === 304) {
// no change
} else if (res.ok) {
const data = await res.json();
if (data.changed) {
const html = await fetchScratchpadItemHtml(sid);
if (html) replaceSingleScratchpadDom(sid, html);
last = data.updated_at;
}
}
} catch {}
await sleep(5000);
}
}
// Initialize
createControls();
renderLineNumbersForAll();
// 運行更新傳輸:優先 SSE,失敗回退 JSON,若瀏覽器不支援則仍可使用舊方案
const mode = getPref(PREFS.updateMode, 'auto');
const focusedSid = getFocusedScratchpadId();
const trySSE = () => {
try {
return !!startSSE();
} catch {
return false;
}
};
if (focusedSid) {
// Scratchpad-focused transport
if (mode === 'sse' || mode === 'auto') {
if (!startScratchpadSSE(focusedSid)) scratchpadJsonPoll(focusedSid);
} else if (mode === 'json') {
scratchpadJsonPoll(focusedSid);
} else {
// html fallback uses workflow legacy loop (coarse)
jsonPollLoop();
}
} else if (mode === 'sse') {
if (!trySSE()) jsonPollLoop();
} else if (mode === 'json') {
jsonPollLoop();
} else if (mode === 'html') {
// 最末回退:保留原機制(整頁解析)
(async function legacyLoop() {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let lastUpdated = detailEl.dataset.updatedAt || '';
while (true) {
const enabled = getPref(PREFS.autoRefresh, '0') === '1';
if (!enabled) {
await sleep(5000);
continue;
}
try {
const res = await fetch(window.location.href, {
headers: { 'X-Partial': 'scratchpads' },
cache: 'no-store',
});
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newDetail = doc.querySelector('.workflow-detail');
const newUpdated = newDetail ? newDetail.dataset.updatedAt || '' : '';
const newContainer = doc.getElementById('scratchpads-container');
if (newContainer && newUpdated && newUpdated !== lastUpdated) {
container.replaceChildren(...Array.from(newContainer.childNodes));
renderLineNumbersForAll();
const currentView = getPref(`viewer:${workflowId}:view`, 'rendered');
applyViewMode(currentView);
updateLineToggleVisibility(currentView);
lastUpdated = newUpdated;
if (getPref(PREFS.autoScroll, '0') === '1') {
container.scrollTop = container.scrollHeight;
window.scrollTo({ top: document.body.scrollHeight });
}
}
} catch {}
await sleep(5000);
}
})();
} else {
// auto 模式
if (!trySSE()) jsonPollLoop();
}
// 事件委派:點擊每個 scratchpad 的「專注」按鈕
container.addEventListener('click', (e) => {
const btn = e.target.closest('.btn-focus');
if (!btn) return;
const sid = btn.getAttribute('data-scratchpad-id');
if (!sid) return;
const url = new URL(window.location.href);
url.searchParams.set('sp', sid);
window.location.href = url.toString();
});
})();