Exports a CCFOLIA chat log package as ZIP with log.json, index.html, and image files.
// ==UserScript==
// @name CCF Log Package Exporter by Capybara_korea
// @namespace https://greatest.deepsurf.us/users/Capybara_korea/ccf-log-package
// @version 0.0.3
// @description Exports a CCFOLIA chat log package as ZIP with log.json, index.html, and image files.
// @description:ko CCFOLIA 채팅 로그를 log.json, index.html, 이미지 파일이 포함된 ZIP 패키지로 내보냅니다.
// @license Copyright @Capybara_korea. All rights reserved.
// @match https://ccfolia.com/*
// @match https://*.ccfolia.com/*
// @run-at document-start
// @grant none
// @noframes
// ==/UserScript==
(() => {
"use strict";
const STYLE_ID = "ccf-log-package-style";
const EXPORT_BTN_ATTR = "data-ccf-log-package-btn";
const EXPORT_BTN_SELECTOR = `[${EXPORT_BTN_ATTR}="1"]`;
const EDITOR_SELECTOR = 'textarea, input[type="text"], [contenteditable="true"], [role="textbox"]';
const MESSAGE_SCOPE_SELECTOR = '[role="log"], [aria-live="polite"], [aria-live="assertive"], .MuiDrawer-paper, ul.MuiList-root';
const MESSAGE_TEXT_SELECTOR = [
'p.MuiTypography-root.MuiTypography-body2',
'.MuiListItemText-root > p',
'[data-index] p',
'li p'
].join(", ");
const RAW_ATTR = "data-ccf-raw";
const SAFE_UI_ATTR = "data-ccf-safe-markup";
const PACKAGE_VERSION = 1;
const INVIS_START = "\u2063\u2063\u2063";
const INVIS_END = "\u2062\u2062\u2062";
const INVIS_MAP = ["\u200B", "\u200C", "\u200D", "\u2060"];
const INVIS_REVERSE = new Map(INVIS_MAP.map((char, index) => [char, index]));
const LOCAL_IMAGE_TOKEN_PREFIX = "ccf-local://image/";
const LOCAL_IMAGE_STORAGE_PREFIX = "ccf-inline-image:";
const LOCAL_IMAGE_INDEX_KEY = "ccf-inline-image:index";
const LOCAL_IMAGE_MAX_ENTRIES = 24;
const FONT_SIZE_MIN = 1;
const FONT_SIZE_MAX = 200;
const DEFAULT_BLUR_VALUE = "4px";
const CCF_SUITE_REGISTRY_KEY = "ccf-suite-registry-v1";
const CCF_SUITE_SCRIPT_STATE_KEY = "ccf-suite-script-states-v1";
const CCF_SUITE_REGISTER_EVENT = "ccf-suite:register";
const CCF_SUITE_REQUEST_EVENT = "ccf-suite:request-register";
const CCF_LOG_PACKAGE_SCRIPT_INFO = Object.freeze({
id: "ccf-log-package",
name: "CCF Log Package Exporter",
version: "0.0.1",
namespace: "https://greatest.deepsurf.us/users/Capybara_korea/ccf-log-package"
});
const buttonState = {
scheduled: false,
busy: false
};
const LOG_SCAN_MAX_ITERATIONS = 400;
const LOG_SCAN_STEP_RATIO = 0.8;
const LOG_SCAN_MIN_STEP = 160;
const LOG_SETTLE_QUIET_MS = 140;
const LOG_SETTLE_TIMEOUT_MS = 1200;
const OFFICIAL_LOG_CAPTURE_TIMEOUT_MS = 12000;
registerWithCcfSuite(CCF_LOG_PACKAGE_SCRIPT_INFO);
window.addEventListener(CCF_SUITE_REQUEST_EVENT, handleCcfSuiteRegisterRequest);
if (!isCcfSuiteScriptEnabled(CCF_LOG_PACKAGE_SCRIPT_INFO.id)) {
return;
}
init();
function handleCcfSuiteRegisterRequest(event) {
const targetId = event?.detail?.targetId;
if (targetId && targetId !== CCF_LOG_PACKAGE_SCRIPT_INFO.id) return;
registerWithCcfSuite(CCF_LOG_PACKAGE_SCRIPT_INFO);
}
function registerWithCcfSuite(scriptInfo) {
try {
const registry = readCcfSuiteRegistry();
const previous = registry.scripts[scriptInfo.id] && typeof registry.scripts[scriptInfo.id] === "object"
? registry.scripts[scriptInfo.id]
: {};
const now = new Date().toISOString();
const sessionId = typeof window.__CCF_SUITE_MANAGER_SESSION_ID === "string"
? window.__CCF_SUITE_MANAGER_SESSION_ID
: "";
registry.scripts[scriptInfo.id] = {
...previous,
...scriptInfo,
installedAt: previous.installedAt || now,
lastSeenAt: now,
lastSeenUrl: location.href,
lastSeenSessionId: sessionId
};
window.localStorage.setItem(CCF_SUITE_REGISTRY_KEY, JSON.stringify(registry));
window.dispatchEvent(
new CustomEvent(CCF_SUITE_REGISTER_EVENT, {
detail: registry.scripts[scriptInfo.id]
})
);
} catch (error) {
// Ignore suite registration failures.
}
}
function readCcfSuiteRegistry() {
try {
const parsed = JSON.parse(window.localStorage.getItem(CCF_SUITE_REGISTRY_KEY) || "{}");
return parsed && typeof parsed.scripts === "object"
? { scripts: parsed.scripts }
: { scripts: {} };
} catch (error) {
return { scripts: {} };
}
}
function isCcfSuiteScriptEnabled(scriptId) {
try {
const parsed = JSON.parse(window.localStorage.getItem(CCF_SUITE_SCRIPT_STATE_KEY) || "{}");
return !parsed || typeof parsed !== "object" || parsed[scriptId] !== false;
} catch (error) {
return true;
}
}
function init() {
injectStyle();
scheduleEnsureButtons();
observeDom();
}
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.ccf-log-package-menu-item {
position: relative;
}
.ccf-log-package-menu-label {
pointer-events: none;
}
.ccf-log-package-menu-item[data-busy="1"] {
opacity: 0.68;
pointer-events: none;
}
.ccf-log-package-menu-item[data-busy="1"]::after {
content: "";
position: absolute;
top: 50%;
right: 16px;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 0;
border: 2px solid currentColor;
border-right-color: transparent;
animation: ccf-log-package-spin 1s linear infinite;
}
@keyframes ccf-log-package-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
(document.head || document.documentElement).appendChild(style);
}
function observeDom() {
const observer = new MutationObserver(() => {
scheduleEnsureButtons();
});
observer.observe(document.documentElement || document.body, {
childList: true,
subtree: true
});
}
function scheduleEnsureButtons() {
if (buttonState.scheduled) return;
buttonState.scheduled = true;
requestAnimationFrame(() => {
buttonState.scheduled = false;
ensureExportButtons();
});
}
function ensureExportButtons() {
const menus = findTargetMenus();
for (const menu of menus) {
if (!(menu instanceof HTMLElement)) continue;
const anchors = findMenuAnchors(menu);
if (!anchors.exportLogsItem && !anchors.tabEditItem) continue;
const existingButtons = cleanupDuplicateExportButtons(menu, anchors);
if (existingButtons.length) {
existingButtons.forEach((button) => syncExportButtonState(button));
continue;
}
const referenceItem = anchors.tabEditItem || anchors.exportLogsItem;
const button = createExportButton(referenceItem);
const parent = referenceItem?.parentElement || menu;
if (anchors.tabEditItem && anchors.tabEditItem.parentElement === parent) {
parent.insertBefore(button, anchors.tabEditItem);
} else if (anchors.exportLogsItem && anchors.exportLogsItem.parentElement === parent) {
anchors.exportLogsItem.insertAdjacentElement("afterend", button);
} else {
parent.appendChild(button);
}
}
}
function cleanupDuplicateExportButtons(menu, anchors) {
const buttons = [...menu.querySelectorAll(EXPORT_BTN_SELECTOR)]
.filter((button) => button instanceof HTMLElement)
.filter((button) => button.closest('[role="menu"]') === menu);
if (buttons.length <= 1) return buttons;
const parent =
anchors.tabEditItem?.parentElement ||
anchors.exportLogsItem?.parentElement ||
buttons[0]?.parentElement ||
menu;
const sorted = buttons.slice().sort((left, right) => {
if (left.parentElement !== parent) return 1;
if (right.parentElement !== parent) return -1;
return getNodeIndex(left) - getNodeIndex(right);
});
const keep = sorted[0];
for (const button of sorted.slice(1)) {
button.remove();
}
return keep ? [keep] : [];
}
function getNodeIndex(node) {
if (!(node instanceof Node) || !node.parentNode) return Number.MAX_SAFE_INTEGER;
return Array.prototype.indexOf.call(node.parentNode.childNodes, node);
}
function createExportButton(referenceItem = null) {
const button = document.createElement("li");
button.className = cleanupMenuItemClassName(referenceItem?.className || "MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters");
button.classList.add("ccf-log-package-menu-item");
button.setAttribute(EXPORT_BTN_ATTR, "1");
button.setAttribute("role", "menuitem");
button.setAttribute("tabindex", "-1");
button.setAttribute("aria-label", "카피바라 패키지");
button.setAttribute("title", "카피바라 패키지");
button.dataset.defaultLabel = "카피바라 패키지";
const label = document.createElement("span");
label.className = "ccf-log-package-menu-label";
button.appendChild(label);
const ripple = referenceItem?.querySelector?.(".MuiTouchRipple-root");
if (ripple instanceof HTMLElement) {
button.appendChild(ripple.cloneNode(false));
}
syncExportButtonState(button);
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void handleExport(button);
});
return button;
}
async function handleExport(originButton = null) {
if (buttonState.busy) return;
setButtonsBusy(true);
try {
const result = await buildLogPackage(originButton);
downloadBlob(result.fileName, result.blob);
} catch (error) {
console.error("[CCF LOG PACKAGE] export failed", error);
alert(error?.message || "로그 패키지를 만들지 못했습니다.");
} finally {
setButtonsBusy(false);
}
}
function setButtonsBusy(nextBusy) {
buttonState.busy = !!nextBusy;
document.querySelectorAll(EXPORT_BTN_SELECTOR).forEach((button) => {
syncExportButtonState(button);
button.setAttribute("aria-disabled", buttonState.busy ? "true" : "false");
});
}
function syncExportButtonState(button) {
if (!(button instanceof HTMLElement)) return;
button.dataset.busy = buttonState.busy ? "1" : "0";
const label = button.querySelector(".ccf-log-package-menu-label");
if (label instanceof HTMLElement) {
label.textContent = buttonState.busy
? "카피바라 패키지 생성 중..."
: (button.dataset.defaultLabel || "카피바라 패키지");
}
}
async function buildLogPackage(originButton = null) {
const exportedAt = new Date();
const roomTitle = getRoomTitle();
const roomAddress = getRoomAddressLabel();
const officialLog = await captureOfficialLogHtml(originButton);
const entries = officialLog?.html
? parseOfficialLogEntries(officialLog.html)
: await collectLogEntries();
if (!entries.length) {
throw new Error("내보낼 채팅 로그를 찾지 못했습니다.");
}
await enrichEntriesWithLiveAvatars(entries);
const assets = await buildAssetBundle(entries);
const assetMap = new Map(assets.map((asset) => [asset.source, asset]));
for (const entry of entries) {
entry.packageHtml = rewriteEntryHtmlForPackage(entry.bodyHtml, assetMap);
}
const currentThemeDefinition = getPackageThemeDefinition();
const themeOptionModel = getPackageThemeOptionModel(currentThemeDefinition.mode);
const themeDefinition = themeOptionModel.definitions[themeOptionModel.selectedMode] || currentThemeDefinition;
const logJson = buildLogJson({
roomTitle,
exportedAt,
entries,
assets
});
const tistoryContentHtmlByMode = themeOptionModel.options.reduce((out, option) => {
const optionTheme = themeOptionModel.definitions[option.value] || themeDefinition;
out[option.value] = buildTistoryContentHtml({
roomTitle,
exportedAt,
entries,
assets,
themeDefinition: optionTheme
});
return out;
}, {});
const tistoryContentHtml = tistoryContentHtmlByMode[themeOptionModel.selectedMode] || "";
const indexHtml = buildIndexHtml({
roomTitle,
exportedAt,
entries,
assets,
tistoryContentHtml,
themeDefinition,
themeOptionModel,
tistoryContentHtmlByMode
});
const zipEntries = [
makeZipEntry("log.json", encodeUtf8(logJson), exportedAt),
makeZipEntry("index.html", encodeUtf8(indexHtml), exportedAt)
];
for (const asset of assets) {
if (!asset.included || !(asset.bytes instanceof Uint8Array) || !asset.fileName) continue;
zipEntries.push(makeZipEntry(asset.fileName, asset.bytes, exportedAt));
}
const zipBytes = buildStoredZip(zipEntries);
return {
fileName: buildPackageFileName(roomTitle, roomAddress, exportedAt),
blob: new Blob([zipBytes], { type: "application/zip" })
};
}
async function captureOfficialLogHtml(originButton = null) {
const officialItem = findOfficialExportMenuItem(originButton);
if (!(officialItem instanceof HTMLElement)) return null;
try {
return await interceptOfficialLogDownload(() => {
officialItem.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window
}));
});
} catch (error) {
console.warn("[CCF LOG PACKAGE] official log capture failed; falling back to live DOM scan", error);
return null;
}
}
function findOfficialExportMenuItem(originButton = null) {
const menu = originButton?.closest?.('[role="menu"]');
if (menu instanceof HTMLElement) {
const anchors = findMenuAnchors(menu);
if (anchors.exportLogsItem instanceof HTMLElement) {
return anchors.exportLogsItem;
}
}
for (const candidateMenu of findTargetMenus()) {
const anchors = findMenuAnchors(candidateMenu);
if (anchors.exportLogsItem instanceof HTMLElement) {
return anchors.exportLogsItem;
}
}
return null;
}
function interceptOfficialLogDownload(trigger) {
return new Promise((resolve, reject) => {
const objectUrlMap = new Map();
const originalCreateObjectURL = URL.createObjectURL.bind(URL);
const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL);
const originalClick = HTMLAnchorElement.prototype.click;
const originalDispatchEvent = HTMLAnchorElement.prototype.dispatchEvent;
let timeoutId = 0;
let settled = false;
const restore = () => {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
HTMLAnchorElement.prototype.click = originalClick;
HTMLAnchorElement.prototype.dispatchEvent = originalDispatchEvent;
clearTimeout(timeoutId);
};
const finish = (result, error = null) => {
if (settled) return;
settled = true;
restore();
if (error) {
reject(error);
return;
}
resolve(result);
};
const tryCaptureAnchor = (anchor) => {
if (!(anchor instanceof HTMLAnchorElement)) return false;
const href = anchor.href || anchor.getAttribute("href") || "";
const download = anchor.download || anchor.getAttribute("download") || "";
const blob = objectUrlMap.get(href);
if (!blob || !looksLikeOfficialLogBlob(blob, download)) return false;
blob.text()
.then((html) => finish({ html, fileName: download || "" }))
.catch((error) => finish(null, error));
return true;
};
URL.createObjectURL = function createObjectURLPatched(blob) {
const url = originalCreateObjectURL(blob);
objectUrlMap.set(url, blob);
return url;
};
URL.revokeObjectURL = function revokeObjectURLPatched(url) {
objectUrlMap.delete(url);
return originalRevokeObjectURL(url);
};
HTMLAnchorElement.prototype.click = function clickPatched(...args) {
if (tryCaptureAnchor(this)) return;
return originalClick.apply(this, args);
};
HTMLAnchorElement.prototype.dispatchEvent = function dispatchEventPatched(event) {
if (event?.type === "click" && tryCaptureAnchor(this)) return true;
return originalDispatchEvent.call(this, event);
};
timeoutId = window.setTimeout(() => {
finish(null, new Error("공식 로그 HTML을 가로채는 시간이 초과되었습니다."));
}, OFFICIAL_LOG_CAPTURE_TIMEOUT_MS);
Promise.resolve()
.then(() => trigger())
.catch((error) => finish(null, error));
});
}
function looksLikeOfficialLogBlob(blob, downloadName = "") {
const type = String(blob?.type || "").toLowerCase();
const name = String(downloadName || "").toLowerCase();
if (type.includes("html")) return true;
return /\.html?$/.test(name);
}
async function collectLogEntries() {
const scope = findPrimaryLogScope();
if (!scope) return [];
const scroller = findLogScrollContainer(scope);
if (!scroller) {
const elements = findLogMessageElements([scope]);
return elements.map((element, index) => buildLogEntry(element, index));
}
const collected = await collectAllLogEntriesFromScroller(scope, scroller);
if (collected.length) return collected;
const fallbackElements = findLogMessageElements([scope]);
return fallbackElements.map((element, index) => buildLogEntry(element, index));
}
function parseOfficialLogEntries(html) {
const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
const paragraphs = [...doc.body.querySelectorAll("p")];
return paragraphs
.map((paragraph, index) => parseOfficialLogParagraph(paragraph, index))
.filter(Boolean);
}
function parseOfficialLogParagraph(paragraph, index) {
if (!(paragraph instanceof HTMLElement)) return null;
const spans = [...paragraph.querySelectorAll(":scope > span")];
const channel = normalizeSpace(spans[0]?.textContent || "");
const sender = normalizeSpace(spans[1]?.textContent || "");
const messageSpan = spans[spans.length - 1] || paragraph;
const rawText = normalizeText(readNodeTextWithBreaks(messageSpan));
const extracted = extractEnvelope(rawText);
const text = extracted?.envelope?.text != null
? normalizeText(String(extracted.envelope.text))
: normalizeText(stripInvisibleEnvelope(rawText));
const baseColor = normalizeCssColor(paragraph.style?.color || "");
const bodyHtml = buildRenderedMessageHtml({
text,
formatRuns: extracted?.envelope?.formatRuns || [],
alignRuns: extracted?.envelope?.alignRuns || [],
blockStyle: extracted?.envelope?.blockStyle || {},
baseColor
});
const assetSources = collectAssetSourcesFromHtml(bodyHtml);
return {
index: index + 1,
id: `official-${index + 1}`,
sender,
avatarSource: "",
timestamp: "",
metaTexts: channel ? [channel, sender].filter(Boolean) : [sender].filter(Boolean),
channel,
text,
visibleText: text,
rawText,
baseColor,
formatEnvelopeVersion: extracted?.envelope?.v ?? null,
formatRuns: cloneJson(extracted?.envelope?.formatRuns || []),
alignRuns: cloneJson(extracted?.envelope?.alignRuns || []),
blockStyle: cloneJson(extracted?.envelope?.blockStyle || {}),
assetSources,
bodyHtml,
packageHtml: ""
};
}
function readNodeTextWithBreaks(node) {
if (!node) return "";
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || "";
}
if (node.nodeName === "BR") {
return "\n";
}
return [...node.childNodes].map((child) => readNodeTextWithBreaks(child)).join("");
}
function buildRenderedMessageHtml({ text, formatRuns, alignRuns, blockStyle, baseColor }) {
const wrapper = document.createElement("div");
wrapper.className = "ccf-render-root";
renderStyledText(wrapper, text || "", formatRuns || [], getEffectiveAlignRuns(text || "", alignRuns || [], blockStyle || {}));
return wrapper.innerHTML;
}
function clamp(value, min, max) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return min;
if (numeric < min) return min;
if (numeric > max) return max;
return numeric;
}
function normalizeCssColor(value) {
if (value == null) return "";
const trimmed = String(value).trim();
if (!trimmed) return "";
const probe = document.createElement("span");
probe.style.color = "";
probe.style.color = trimmed;
return probe.style.color || "";
}
function renderStyledText(container, text, runs, alignRuns = []) {
if (!container) return;
if (!text) {
container.style.textAlign = "";
container.textContent = "";
return;
}
const normalizedRuns = normalizeRuns(runs, text.length);
const normalizedAlignRuns = getEffectiveAlignRuns(text, alignRuns);
if (!normalizedRuns.length && !normalizedAlignRuns.length) {
container.style.textAlign = "";
container.textContent = text;
return;
}
container.innerHTML = "";
container.style.textAlign = "";
const lines = getTextLines(text);
let activeCodeGroup = null;
let activeCodeGroupKey = "";
for (const line of lines) {
const lineEl = document.createElement("span");
lineEl.className = "ccf-line";
lineEl.dataset.ccfLine = "1";
lineEl.dataset.lineIndex = String(line.index);
lineEl.dataset.start = String(line.start);
lineEl.dataset.end = String(line.end);
lineEl.style.textAlign = getLineAlign(normalizedAlignRuns, line.index);
const lineRuns = normalizedRuns
.filter((run) => run.start < line.end && run.end > line.start)
.map((run) => ({
start: clamp(run.start - line.start, 0, line.text.length),
end: clamp(run.end - line.start, 0, line.text.length),
style: { ...run.style }
}))
.filter((run) => run.end > run.start);
if (!line.text.length) {
const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns);
lineEl.appendChild(document.createElement("br"));
if (blockCodeGroupKey) {
if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
activeCodeGroup = document.createElement("span");
activeCodeGroup.className = "ccf-frag ccf-code-frag is-block ccf-code-block-group";
activeCodeGroupKey = blockCodeGroupKey;
container.appendChild(activeCodeGroup);
}
activeCodeGroup.appendChild(lineEl);
continue;
}
activeCodeGroup = null;
activeCodeGroupKey = "";
} else if (!lineRuns.length) {
lineEl.textContent = line.text;
activeCodeGroup = null;
activeCodeGroupKey = "";
} else {
const fragments = buildFragments(line.text, lineRuns);
const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns, fragments);
if (blockCodeGroupKey) {
if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
activeCodeGroup = document.createElement("span");
activeCodeGroup.className = "ccf-frag ccf-code-frag is-block ccf-code-block-group";
activeCodeGroupKey = blockCodeGroupKey;
container.appendChild(activeCodeGroup);
}
for (const frag of fragments) {
appendStyledFragment(lineEl, {
...frag,
style: stripCodeModeFromStyle(frag.style)
});
}
activeCodeGroup.appendChild(lineEl);
continue;
}
activeCodeGroup = null;
activeCodeGroupKey = "";
for (const frag of fragments) {
appendStyledFragment(lineEl, frag);
}
}
container.appendChild(lineEl);
}
}
function normalizeRuns(runs, textLength) {
if (!Array.isArray(runs)) return [];
const cleaned = runs
.map((run) => ({
start: clamp(Number(run.start) || 0, 0, textLength),
end: clamp(Number(run.end) || 0, 0, textLength),
style: cleanupStyle(run.style || {})
}))
.filter((run) => run.end > run.start && Object.keys(run.style).length > 0)
.sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
for (const cur of cleaned) {
const prev = merged[merged.length - 1];
if (prev && prev.end === cur.start && JSON.stringify(prev.style) === JSON.stringify(cur.style)) {
prev.end = cur.end;
} else {
merged.push(cur);
}
}
return merged;
}
function cleanupStyle(style) {
const out = {};
if (style.bold) out.bold = true;
if (style.italic) out.italic = true;
if (style.underline) out.underline = true;
if (style.strike) out.strike = true;
const rubyText = normalizeRubyText(style.rubyText);
if (rubyText) out.rubyText = rubyText;
const tooltipText = normalizeTooltipText(style.tooltipText);
if (tooltipText) out.tooltipText = tooltipText;
const codeMode = normalizeCodeMode(style.codeMode);
if (codeMode) out.codeMode = codeMode;
const blur = normalizeBlurValue(style.blur);
if (blur) out.blur = blur;
if (style.color && style.color !== "#ffffff") out.color = style.color;
if (style.backgroundColor && style.backgroundColor !== "#000000") out.backgroundColor = style.backgroundColor;
const imageUrl = normalizeImageUrl(style.imageUrl);
if (imageUrl) out.imageUrl = imageUrl;
const imageAlt = normalizeImageAlt(style.imageAlt);
if (imageAlt) out.imageAlt = imageAlt;
if (style.backgroundImage) out.backgroundImage = String(style.backgroundImage).trim();
const fontSize = normalizeFontSizeValue(style.fontSize);
if (fontSize != null) out.fontSize = fontSize;
const display = String(style.display || "").trim().toLowerCase();
if (["inline", "inline-block", "block"].includes(display)) out.display = display;
const padding = String(style.padding || "").trim();
if (padding) out.padding = padding;
const margin = String(style.margin || "").trim();
if (margin) out.margin = margin;
const border = String(style.border || "").trim();
if (border) out.border = border;
const letterSpacing = String(style.letterSpacing || "").trim();
if (letterSpacing) out.letterSpacing = letterSpacing;
const lineHeight = String(style.lineHeight || "").trim();
if (lineHeight) out.lineHeight = lineHeight;
const textAlign = cleanupAlign(style.textAlign);
if (textAlign) out.textAlign = textAlign;
const textShadow = String(style.textShadow || "").trim();
if (textShadow) out.textShadow = textShadow;
const opacity = Number(style.opacity);
if (Number.isFinite(opacity)) out.opacity = clamp(opacity, 0, 1);
return out;
}
function normalizeRubyText(value) {
if (value == null) return "";
return String(value).trim().slice(0, 120);
}
function normalizeTooltipText(value) {
if (value == null) return "";
return String(value)
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
.slice(0, 240);
}
function normalizeCodeMode(value) {
if (value === true) return "inline";
const normalized = String(value ?? "").trim().toLowerCase();
if (normalized === "inline" || normalized === "block") return normalized;
if (normalized === "true" || normalized === "1" || normalized === "code") return "inline";
return "";
}
function normalizeBlurValue(value) {
if (value == null || value === false) return "";
let trimmed = String(value).trim();
if (!trimmed) return "";
const blurMatch = trimmed.match(/blur\(([^)]+)\)/i);
if (blurMatch) {
trimmed = blurMatch[1].trim();
}
if (/^(?:\d+|\d*\.\d+)$/.test(trimmed)) {
trimmed = `${trimmed}px`;
}
const match = trimmed.match(/^(-?(?:\d+|\d*\.\d+))(px|em|rem)$/i);
if (!match) return "";
const amount = Number.parseFloat(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return "";
return `${Number(amount.toFixed(2))}${match[2].toLowerCase()}`;
}
function normalizeImageUrl(value) {
if (typeof value !== "string") return "";
let trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith(LOCAL_IMAGE_TOKEN_PREFIX)) {
return trimmed;
}
if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) {
return trimmed.replace(/\s+/g, "");
}
if (/^\/\//.test(trimmed)) {
trimmed = `https:${trimmed}`;
} else if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /^[\w.-]+\.[a-z]{2,}(?:\/|$)/i.test(trimmed)) {
trimmed = `https://${trimmed}`;
}
try {
const parsed = new URL(trimmed, location.href);
if (!/^https?:$/i.test(parsed.protocol)) return "";
return parsed.toString();
} catch (error) {
return "";
}
}
function normalizeImageAlt(value) {
if (value == null) return "";
return String(value).trim().slice(0, 200);
}
function isLocalImageToken(value) {
return typeof value === "string" && value.startsWith(LOCAL_IMAGE_TOKEN_PREFIX);
}
function getLocalImageTokenId(value) {
return isLocalImageToken(value) ? value.slice(LOCAL_IMAGE_TOKEN_PREFIX.length) : "";
}
function getLocalImageStorageKey(id) {
return `${LOCAL_IMAGE_STORAGE_PREFIX}${id}`;
}
function resolveStoredLocalImageUrl(value) {
const id = getLocalImageTokenId(value);
if (!id) return "";
try {
const stored = window.localStorage.getItem(getLocalImageStorageKey(id));
return /^data:image\/[a-z0-9.+-]+;base64,/i.test(stored || "")
? String(stored).replace(/\s+/g, "")
: "";
} catch (error) {
return "";
}
}
function resolveRenderableImageUrl(value) {
const normalized = normalizeImageUrl(value);
if (!normalized) return "";
if (isLocalImageToken(normalized)) {
return resolveStoredLocalImageUrl(normalized);
}
return normalized;
}
function normalizeFontSizeValue(value) {
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const numeric = Math.round(Number(trimmed));
if (!Number.isFinite(numeric)) return null;
return clamp(numeric, FONT_SIZE_MIN, FONT_SIZE_MAX);
}
function getTextLines(text) {
const normalized = typeof text === "string" ? text : "";
if (!normalized.length) {
return [{ index: 0, start: 0, end: 0, text: "", hasBreak: false }];
}
const out = [];
let start = 0;
let lineIndex = 0;
for (let i = 0; i <= normalized.length; i += 1) {
if (i !== normalized.length && normalized[i] !== "\n") continue;
out.push({
index: lineIndex,
start,
end: i,
text: normalized.slice(start, i),
hasBreak: i < normalized.length
});
start = i + 1;
lineIndex += 1;
}
return out;
}
function getTextLineCount(text) {
return getTextLines(text).length;
}
function cleanupAlign(value) {
return value === "center" || value === "right" ? value : null;
}
function normalizeAlignRuns(runs, lineCount) {
if (!Array.isArray(runs)) return [];
const cleaned = runs
.map((run) => ({
start: clamp(Number(run.start) || 0, 0, lineCount),
end: clamp(Number(run.end) || 0, 0, lineCount),
align: cleanupAlign(run.align)
}))
.filter((run) => run.end > run.start && !!run.align)
.sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
for (const cur of cleaned) {
const prev = merged[merged.length - 1];
if (prev && prev.end >= cur.start) {
if (prev.align === cur.align) {
prev.end = Math.max(prev.end, cur.end);
continue;
}
if (prev.end > cur.start) {
cur.start = prev.end;
}
}
if (cur.end > cur.start) {
merged.push(cur);
}
}
return merged;
}
function cleanupBlockStyle(style) {
const out = {};
if (style && ["center", "right"].includes(style.align)) {
out.align = style.align;
}
return out;
}
function getLegacyAlignRuns(text, blockStyle) {
const legacy = cleanupBlockStyle(blockStyle);
const align = cleanupAlign(legacy.align);
if (!align) return [];
return [{ start: 0, end: getTextLineCount(text), align }];
}
function getEffectiveAlignRuns(text, alignRuns, blockStyle = null) {
const normalized = normalizeAlignRuns(alignRuns, getTextLineCount(text));
if (normalized.length) return normalized;
return getLegacyAlignRuns(text, blockStyle);
}
function getLineAlign(alignRuns, lineIndex) {
const run = alignRuns.find((item) => item.start <= lineIndex && item.end > lineIndex);
return run?.align || "";
}
function buildFragments(text, runs) {
const points = new Set([0, text.length]);
for (const run of runs) {
points.add(run.start);
points.add(run.end);
}
const sorted = [...points].sort((a, b) => a - b);
const out = [];
for (let i = 0; i < sorted.length - 1; i += 1) {
const start = sorted[i];
const end = sorted[i + 1];
if (start === end) continue;
out.push({
text: text.slice(start, end),
style: mergeStyles(
runs
.filter((run) => run.start <= start && run.end >= end)
.map((run) => run.style)
)
});
}
return out;
}
function stripCodeModeFromStyle(style) {
if (!style || !Object.prototype.hasOwnProperty.call(style, "codeMode")) {
return style ? { ...style } : style;
}
const nextStyle = { ...style };
delete nextStyle.codeMode;
return nextStyle;
}
function getBlockCodeGroupKeyForLine(line, runs, fragments = null) {
const coveringRun = runs.find((run) =>
normalizeCodeMode(run.style?.codeMode) === "block" &&
run.start <= line.start &&
run.end >= line.end
);
if (!coveringRun) return "";
if (!line.text.length) return `${coveringRun.start}:${coveringRun.end}`;
if (!Array.isArray(fragments) || !fragments.length) return "";
return fragments.every((frag) => normalizeCodeMode(frag.style?.codeMode) === "block")
? `${coveringRun.start}:${coveringRun.end}`
: "";
}
function mergeStyles(styleList) {
const out = {};
for (const style of styleList) {
if (style) Object.assign(out, style);
}
return out;
}
function applyInlineStyle(el, style) {
if (!style) return;
if (style.bold) el.style.fontWeight = "700";
if (style.italic) el.style.fontStyle = "italic";
if (style.underline || style.strike) {
const parts = [];
if (style.underline) parts.push("underline");
if (style.strike) parts.push("line-through");
el.style.textDecoration = parts.join(" ");
}
if (style.color) el.style.color = style.color;
if (style.backgroundColor) el.style.backgroundColor = style.backgroundColor;
if (style.backgroundImage) el.style.backgroundImage = style.backgroundImage;
if (style.fontSize) el.style.fontSize = `${style.fontSize}px`;
if (style.display) el.style.display = style.display;
if (style.padding) el.style.padding = style.padding;
if (style.margin) el.style.margin = style.margin;
if (style.border) el.style.border = style.border;
if (style.letterSpacing) el.style.letterSpacing = style.letterSpacing;
if (style.lineHeight) el.style.lineHeight = style.lineHeight;
if (style.textAlign) el.style.textAlign = style.textAlign;
if (style.textShadow) el.style.textShadow = style.textShadow;
if (style.blur) el.style.filter = `blur(${style.blur})`;
if (style.opacity != null) el.style.opacity = String(style.opacity);
}
function appendStyledFragment(container, frag) {
if (!container || !frag) return;
container.appendChild(createStyledFragmentNode(frag));
}
function createStyledFragmentNode(frag) {
if (frag.style?.imageUrl) return createImageFragmentNode(frag);
if (frag.style?.tooltipText) return createTooltipFragmentNode(frag);
if (frag.style?.codeMode) return createCodeFragmentNode(frag);
if (frag.style?.rubyText) return createRubyFragmentNode(frag);
return createPlainTextFragmentNode(frag);
}
function createPlainTextFragmentNode(frag) {
const span = document.createElement("span");
span.className = "ccf-frag";
span.textContent = frag.text || "";
applyInlineStyle(span, frag.style);
return span;
}
function createTooltipFragmentNode(frag) {
const tooltipText = normalizeTooltipText(frag.style?.tooltipText);
if (!tooltipText) {
const fallbackStyle = frag.style ? { ...frag.style } : null;
if (fallbackStyle) delete fallbackStyle.tooltipText;
return createStyledFragmentNode({ ...frag, style: fallbackStyle });
}
const wrapper = document.createElement("span");
wrapper.className = "ccf-frag ccf-tooltip-frag";
wrapper.dataset.tooltip = tooltipText;
wrapper.dataset.tooltipMultiline = tooltipText.includes("\n") ? "1" : "0";
const innerStyle = frag.style ? { ...frag.style } : null;
if (innerStyle) delete innerStyle.tooltipText;
wrapper.appendChild(createStyledFragmentNode({ ...frag, style: innerStyle }));
return wrapper;
}
function createCodeFragmentNode(frag) {
const codeMode = normalizeCodeMode(frag.style?.codeMode);
if (!codeMode) {
const fallbackStyle = frag.style ? { ...frag.style } : null;
if (fallbackStyle) delete fallbackStyle.codeMode;
return createStyledFragmentNode({ ...frag, style: fallbackStyle });
}
const wrapper = document.createElement("span");
wrapper.className = `ccf-frag ccf-code-frag is-${codeMode}`;
const innerStyle = frag.style ? { ...frag.style } : null;
if (innerStyle) delete innerStyle.codeMode;
wrapper.appendChild(createStyledFragmentNode({ ...frag, style: innerStyle }));
return wrapper;
}
function createRubyFragmentNode(frag) {
const rubyText = normalizeRubyText(frag.style?.rubyText);
if (!rubyText) {
const fallback = document.createElement("span");
fallback.className = "ccf-frag";
fallback.textContent = frag.text || "";
applyInlineStyle(fallback, frag.style);
return fallback;
}
const wrapper = document.createElement("span");
wrapper.className = "ccf-frag ccf-ruby-frag";
wrapper.dataset.ruby = rubyText;
if (frag.style?.color) wrapper.style.color = frag.style.color;
if (frag.style?.fontSize) wrapper.style.fontSize = `${frag.style.fontSize}px`;
if (frag.style?.bold) wrapper.style.fontWeight = "700";
if (frag.style?.italic) wrapper.style.fontStyle = "italic";
if (frag.style?.letterSpacing) wrapper.style.letterSpacing = frag.style.letterSpacing;
if (frag.style?.lineHeight) wrapper.style.lineHeight = frag.style.lineHeight;
if (frag.style?.blur) wrapper.style.filter = `blur(${frag.style.blur})`;
const base = document.createElement("span");
base.className = "ccf-ruby-base";
base.textContent = frag.text || "";
const baseStyle = frag.style ? { ...frag.style } : null;
if (baseStyle) delete baseStyle.blur;
applyInlineStyle(base, baseStyle);
wrapper.appendChild(base);
return wrapper;
}
function createImageFragmentNode(frag) {
const wrapper = document.createElement("span");
wrapper.className = "ccf-image-frag";
const token = document.createElement("span");
token.className = "ccf-image-token";
token.textContent = frag.text || "";
wrapper.appendChild(token);
const imageUrl = resolveRenderableImageUrl(frag.style.imageUrl);
if (!imageUrl) {
const fallback = document.createElement("span");
fallback.textContent = frag.style.imageAlt || frag.text || "image";
applyInlineStyle(fallback, frag.style);
wrapper.appendChild(fallback);
return wrapper;
}
const img = document.createElement("img");
img.className = "ccf-image";
img.src = imageUrl;
img.alt = frag.style.imageAlt || frag.text || "image";
img.loading = "lazy";
img.decoding = "async";
applyInlineStyle(img, frag.style);
wrapper.appendChild(img);
return wrapper;
}
function findLogMessageElements(scopes = findChatLogScopes()) {
const seen = new Set();
const out = [];
for (const scope of scopes) {
if (!(scope instanceof Element)) continue;
if (scope.matches?.(MESSAGE_TEXT_SELECTOR) && isLogMessageTextElement(scope)) {
seen.add(scope);
out.push(scope);
}
scope.querySelectorAll?.(MESSAGE_TEXT_SELECTOR).forEach((element) => {
if (!(element instanceof HTMLElement)) return;
if (seen.has(element)) return;
if (!isLogMessageTextElement(element)) return;
seen.add(element);
out.push(element);
});
}
return out;
}
function findPrimaryLogScope() {
const scopes = findChatLogScopes()
.filter((scope) => scope instanceof HTMLElement && isVisible(scope));
if (!scopes.length) return null;
scopes.sort((left, right) => {
const rightCount = right.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
const leftCount = left.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
if (rightCount !== leftCount) return rightCount - leftCount;
const rightRect = right.getBoundingClientRect();
const leftRect = left.getBoundingClientRect();
return (rightRect.height * rightRect.width) - (leftRect.height * leftRect.width);
});
return scopes[0] || null;
}
function findLogScrollContainer(scope) {
if (!(scope instanceof HTMLElement)) return null;
let current = scope;
while (current && current !== document.body) {
if (isScrollableElement(current)) {
return current;
}
current = current.parentElement;
}
return findScrollableElementInDrawer(scope.closest(".MuiDrawer-paper")) || null;
}
function findScrollableElementInDrawer(drawer) {
if (!(drawer instanceof HTMLElement)) return null;
const candidates = [drawer, ...drawer.querySelectorAll("*")];
const scrollables = candidates
.filter((element) => element instanceof HTMLElement)
.filter((element) => isScrollableElement(element))
.sort((left, right) => right.clientHeight - left.clientHeight);
return scrollables[0] || null;
}
function isScrollableElement(element) {
if (!(element instanceof HTMLElement)) return false;
const style = getComputedStyle(element);
if (!/(auto|scroll|overlay)/i.test(style.overflowY || "")) return false;
return element.scrollHeight > element.clientHeight + 24;
}
async function collectAllLogEntriesFromScroller(scope, scroller) {
const originalTop = scroller.scrollTop;
const originalBehavior = scroller.style.scrollBehavior;
const entries = [];
const seenFingerprints = [];
scroller.style.scrollBehavior = "auto";
try {
await scrollLogToStart(scroller, scope);
await waitForLogSettle(scope);
for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
appendVisibleEntries(scope, entries, seenFingerprints);
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (scroller.scrollTop >= maxTop - 1) {
await waitForLogSettle(scope);
appendVisibleEntries(scope, entries, seenFingerprints);
break;
}
const step = Math.max(LOG_SCAN_MIN_STEP, Math.floor(scroller.clientHeight * LOG_SCAN_STEP_RATIO));
const nextTop = Math.min(maxTop, scroller.scrollTop + step);
if (nextTop === scroller.scrollTop) {
break;
}
scroller.scrollTop = nextTop;
await waitForLogSettle(scope);
}
} finally {
scroller.scrollTop = originalTop;
scroller.style.scrollBehavior = originalBehavior;
}
return entries.map((entry, index) => ({
...entry,
index: index + 1
}));
}
async function scrollLogToStart(scroller, scope) {
let stableCount = 0;
let previousSignature = "";
let previousHeight = -1;
for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
scroller.scrollTop = 0;
await waitForLogSettle(scope);
const firstElement = findLogMessageElements([scope])[0] || null;
const signature = firstElement ? getElementFingerprint(firstElement) : "";
const height = scroller.scrollHeight;
const atTop = scroller.scrollTop <= 1;
if (atTop && signature === previousSignature && height === previousHeight) {
stableCount += 1;
} else {
stableCount = 0;
}
if (stableCount >= 2) {
break;
}
previousSignature = signature;
previousHeight = height;
}
}
function appendVisibleEntries(scope, entries, seenFingerprints) {
const visibleElements = findLogMessageElements([scope]);
for (const element of visibleElements) {
const entry = buildLogEntry(element, entries.length);
const fingerprint = getEntryFingerprint(entry);
if (!fingerprint) continue;
if (hasRecentFingerprint(seenFingerprints, fingerprint)) continue;
seenFingerprints.push(fingerprint);
if (seenFingerprints.length > 200) {
seenFingerprints.splice(0, seenFingerprints.length - 200);
}
entries.push(entry);
}
}
function hasRecentFingerprint(fingerprints, value) {
for (let i = fingerprints.length - 1; i >= 0; i -= 1) {
if (fingerprints[i] === value) return true;
}
return false;
}
function getEntryFingerprint(entry) {
if (!entry) return "";
if (entry.id && !/^message-\d+$/.test(entry.id)) {
return `id:${entry.id}`;
}
return [
entry.sender || "",
entry.timestamp || "",
entry.rawText || "",
entry.text || "",
entry.visibleText || ""
].join("\n@@\n");
}
function getElementFingerprint(element) {
return getEntryFingerprint(buildLogEntry(element, 0));
}
async function waitForLogSettle(scope) {
await new Promise((resolve) => {
let done = false;
let quietTimer = 0;
let timeoutTimer = 0;
const observer = new MutationObserver(() => {
restartQuietTimer();
});
const finish = () => {
if (done) return;
done = true;
clearTimeout(quietTimer);
clearTimeout(timeoutTimer);
observer.disconnect();
resolve();
};
const restartQuietTimer = () => {
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, LOG_SETTLE_QUIET_MS);
};
if (scope instanceof Element) {
observer.observe(scope, {
childList: true,
subtree: true,
characterData: true
});
}
timeoutTimer = setTimeout(finish, LOG_SETTLE_TIMEOUT_MS);
restartQuietTimer();
});
await waitForAnimationFrame();
await waitForAnimationFrame();
}
function waitForAnimationFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function findChatLogScopes() {
const scopes = new Set();
const drawers = new Set();
for (const composer of findComposerBars()) {
const drawer = composer.closest(".MuiDrawer-paper");
if (drawer instanceof HTMLElement) {
drawers.add(drawer);
}
}
if (!drawers.size) {
document.querySelectorAll(".MuiDrawer-paper").forEach((drawer) => {
if (!(drawer instanceof HTMLElement)) return;
if (!drawer.querySelector('button[type="submit"]')) return;
drawers.add(drawer);
});
}
if (drawers.size) {
for (const drawer of drawers) {
if (drawer.matches?.(MESSAGE_SCOPE_SELECTOR)) {
scopes.add(drawer);
}
drawer.querySelectorAll?.(MESSAGE_SCOPE_SELECTOR).forEach((scope) => {
scopes.add(scope);
});
}
return [...scopes];
}
document.querySelectorAll(MESSAGE_SCOPE_SELECTOR).forEach((scope) => {
scopes.add(scope);
});
return [...scopes];
}
function isLogMessageTextElement(element) {
if (!(element instanceof HTMLElement)) return false;
if (!element.matches?.(MESSAGE_TEXT_SELECTOR)) return false;
if (!element.closest(MESSAGE_SCOPE_SELECTOR)) return false;
if (element.closest(`[${SAFE_UI_ATTR}="1"]`)) return false;
if (element.closest('button, form, [role="dialog"]')) return false;
if (element.closest('textarea, input, [contenteditable="true"], [role="textbox"]')) return false;
if (element.querySelector(MESSAGE_TEXT_SELECTOR)) return false;
const rawText = normalizeText(element.getAttribute(RAW_ATTR) || "");
const visibleText = normalizeText(
typeof element.innerText === "string" ? element.innerText : (element.textContent || "")
);
return !!(rawText.trim() || visibleText.trim());
}
function buildLogEntry(element, index) {
const rawText = normalizeText(element.getAttribute(RAW_ATTR) || element.textContent || "");
const visibleText = normalizeText(
typeof element.innerText === "string" ? element.innerText : stripInvisibleEnvelope(element.textContent || "")
);
const extracted = extractEnvelope(rawText);
const text = extracted?.envelope?.text != null
? normalizeText(String(extracted.envelope.text))
: normalizeText(stripInvisibleEnvelope(rawText || visibleText));
const itemRoot = findMessageItemRoot(element);
const meta = extractMessageMeta(itemRoot, element, visibleText);
const bodyHtml = captureBodyHtml(element, !!extracted, text);
const assetSources = collectAssetSourcesFromHtml(bodyHtml);
const avatarSource = extractMessageAvatarSource(itemRoot, element);
if (avatarSource && !assetSources.includes(avatarSource)) {
assetSources.unshift(avatarSource);
}
const baseColor = normalizeCssColor(element.style?.color || "");
return {
index: index + 1,
id: getMessageId(itemRoot, index),
sender: meta.sender,
avatarSource,
timestamp: meta.timestamp,
metaTexts: meta.metaTexts,
channel: "",
text,
visibleText,
rawText,
baseColor,
formatEnvelopeVersion: extracted?.envelope?.v ?? null,
formatRuns: cloneJson(extracted?.envelope?.formatRuns || []),
alignRuns: cloneJson(extracted?.envelope?.alignRuns || []),
blockStyle: cloneJson(extracted?.envelope?.blockStyle || {}),
assetSources,
bodyHtml,
packageHtml: ""
};
}
function captureBodyHtml(element, hasEnvelope, text) {
const wrapper = document.createElement("div");
wrapper.className = "ccf-render-root";
const alreadyRendered =
element.classList.contains("ccf-render-root") ||
element.hasAttribute(RAW_ATTR) ||
!!element.querySelector(".ccf-line, .ccf-frag, .ccf-image, .ccf-code-frag, .ccf-ruby-frag, .ccf-tooltip-frag");
if (alreadyRendered || !hasEnvelope) {
wrapper.innerHTML = element.innerHTML;
} else {
wrapper.textContent = text;
}
if (!wrapper.textContent && text) {
wrapper.textContent = text;
}
return wrapper.innerHTML;
}
function findComposerBars() {
const submits = findVisibleSubmitButtons();
const result = new Set();
submits.forEach((submit) => {
const bar = findClosestComposerBar(submit);
if (bar) {
result.add(bar);
}
});
return [...result];
}
function findClosestComposerBar(node) {
let current = node instanceof Element ? node : null;
while (current && current !== document.body) {
if (looksLikeComposerBar(current)) return current;
current = current.parentElement;
}
return null;
}
function looksLikeComposerBar(element) {
if (!(element instanceof HTMLElement)) return false;
const submit = element.querySelector('button[type="submit"]');
if (!submit) return false;
const editors = [...element.querySelectorAll(EDITOR_SELECTOR)].filter((editor) => isVisible(editor));
return editors.length > 0;
}
function findVisibleSubmitButtons() {
return [...document.querySelectorAll('button[type="submit"]')].filter((button) => isVisible(button));
}
function findTargetMenus() {
return [...document.querySelectorAll('[role="menu"]')]
.filter((menu) => menu instanceof HTMLElement && isVisible(menu))
.filter((menu) => {
const anchors = findMenuAnchors(menu);
return !!(anchors.exportLogsItem || anchors.tabEditItem);
});
}
function findMenuAnchors(menu) {
const items = [...menu.querySelectorAll('[role="menuitem"]')]
.filter((item) => item instanceof HTMLElement)
.filter((item) => item.closest('[role="menu"]') === menu)
.filter((item) => !item.hasAttribute(EXPORT_BTN_ATTR));
return {
exportLogsItem: items.find((item) => isExportLogsMenuItem(item)) || null,
tabEditItem: items.find((item) => isTabEditMenuItem(item)) || null
};
}
function isExportLogsMenuItem(item) {
const text = normalizeSpace(item.textContent || "").toLowerCase();
if (!text) return false;
return /export\s*logs?/.test(text)
|| /logs?\s*export/.test(text)
|| /로그\s*(출력|내보내기|익스포트)/.test(text)
|| /ログ/.test(text);
}
function isTabEditMenuItem(item) {
const text = normalizeSpace(item.textContent || "").toLowerCase();
if (!text) return false;
return /탭\s*편집/.test(text)
|| /edit\s*tab/.test(text)
|| /tab\s*edit/.test(text)
|| /タブ\s*編集/.test(text);
}
function cleanupMenuItemClassName(value) {
return String(value || "")
.split(/\s+/)
.filter(Boolean)
.filter((className) => className !== "Mui-disabled")
.join(" ");
}
function findMessageItemRoot(element) {
if (!(element instanceof Element)) return null;
return element.closest('li, [role="listitem"], .MuiListItem-root')
|| findIndexedMessageRoot(element)
|| element.parentElement
|| element;
}
function findIndexedMessageRoot(element) {
if (!(element instanceof Element)) return null;
let current = element;
while (current && current !== document.body) {
if (
current.hasAttribute("data-index") &&
current.querySelector(MESSAGE_TEXT_SELECTOR)
) {
return current;
}
current = current.parentElement;
}
return null;
}
function getMessageId(itemRoot, index) {
if (itemRoot instanceof Element) {
return itemRoot.getAttribute("data-index")
|| itemRoot.getAttribute("data-id")
|| itemRoot.id
|| `message-${index + 1}`;
}
return `message-${index + 1}`;
}
function extractMessageMeta(itemRoot, textElement, visibleText) {
if (!(itemRoot instanceof Element)) {
return { sender: "", timestamp: "", metaTexts: [] };
}
const seen = new Set();
const metaCandidates = [];
const nodes = itemRoot.querySelectorAll('time, h1, h2, h3, h4, h5, h6, strong, b, small, span, div, p');
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
if (node === textElement) return;
if (node.contains(textElement) || textElement.contains(node)) return;
if (node.querySelector(MESSAGE_TEXT_SELECTOR)) return;
const text = normalizeSpace(node.textContent || "");
if (!text || text === normalizeSpace(visibleText) || text.length > 120 || seen.has(text)) return;
seen.add(text);
metaCandidates.push({
text,
score: scoreSenderCandidate(node, text)
});
});
const metaTexts = metaCandidates.map((item) => item.text);
const timestamp = metaTexts.find((text) => looksLikeTimestamp(text)) || "";
const sender = metaCandidates
.filter((item) => item.text !== timestamp && !looksLikeTimestamp(item.text))
.sort((left, right) => right.score - left.score || left.text.length - right.text.length)[0]?.text || "";
return { sender, timestamp, metaTexts };
}
async function enrichEntriesWithLiveAvatars(entries) {
if (!Array.isArray(entries) || !entries.length) return;
const liveAvatarEntries = await collectLiveAvatarEntries();
if (!liveAvatarEntries.length) return;
mergeEntriesWithLiveAvatars(entries, liveAvatarEntries);
}
async function collectLiveAvatarEntries() {
const scope = findPrimaryLogScope();
if (!(scope instanceof HTMLElement)) return [];
const scroller = findLogScrollContainer(scope);
if (!(scroller instanceof HTMLElement)) {
return findLogMessageElements([scope]).map((element, index) => buildLiveAvatarEntry(element, index));
}
return collectLiveAvatarEntriesFromScroller(scope, scroller);
}
async function collectLiveAvatarEntriesFromScroller(scope, scroller) {
const originalTop = scroller.scrollTop;
const originalBehavior = scroller.style.scrollBehavior;
const entries = [];
const seenFingerprints = [];
scroller.style.scrollBehavior = "auto";
try {
await scrollLogToStart(scroller, scope);
await waitForLogSettle(scope);
for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
appendVisibleAvatarEntries(scope, entries, seenFingerprints);
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (scroller.scrollTop >= maxTop - 1) {
await waitForLogSettle(scope);
appendVisibleAvatarEntries(scope, entries, seenFingerprints);
break;
}
const step = Math.max(LOG_SCAN_MIN_STEP, Math.floor(scroller.clientHeight * LOG_SCAN_STEP_RATIO));
const nextTop = Math.min(maxTop, scroller.scrollTop + step);
if (nextTop === scroller.scrollTop) {
break;
}
scroller.scrollTop = nextTop;
await waitForLogSettle(scope);
}
} finally {
scroller.scrollTop = originalTop;
scroller.style.scrollBehavior = originalBehavior;
}
return entries;
}
function appendVisibleAvatarEntries(scope, entries, seenFingerprints) {
const visibleEntries = findLogMessageElements([scope])
.map((element, index) => buildLiveAvatarEntry(element, entries.length + index))
.filter((entry) => !!getLiveAvatarEntryFingerprint(entry));
if (!visibleEntries.length) return;
const visibleFingerprints = visibleEntries.map((entry) => getLiveAvatarEntryFingerprint(entry));
const overlap = getFingerprintOverlapLength(seenFingerprints, visibleFingerprints);
for (let i = overlap; i < visibleEntries.length; i += 1) {
const entry = visibleEntries[i];
const fingerprint = visibleFingerprints[i];
if (!fingerprint) continue;
seenFingerprints.push(fingerprint);
if (seenFingerprints.length > 400) {
seenFingerprints.splice(0, seenFingerprints.length - 400);
}
entries.push(entry);
}
}
function getFingerprintOverlapLength(previousFingerprints, nextFingerprints) {
if (!Array.isArray(previousFingerprints) || !previousFingerprints.length) return 0;
if (!Array.isArray(nextFingerprints) || !nextFingerprints.length) return 0;
const maxOverlap = Math.min(previousFingerprints.length, nextFingerprints.length);
for (let size = maxOverlap; size > 0; size -= 1) {
let matched = true;
for (let i = 0; i < size; i += 1) {
if (previousFingerprints[previousFingerprints.length - size + i] !== nextFingerprints[i]) {
matched = false;
break;
}
}
if (matched) return size;
}
return 0;
}
function buildLiveAvatarEntry(element, index) {
if (!(element instanceof HTMLElement)) {
return {
id: `message-${index + 1}`,
sender: "",
timestamp: "",
mergeKey: "",
avatarSource: ""
};
}
const rawText = normalizeText(element.getAttribute(RAW_ATTR) || element.textContent || "");
const visibleText = normalizeText(
typeof element.innerText === "string" ? element.innerText : stripInvisibleEnvelope(element.textContent || "")
);
const extracted = extractEnvelope(rawText);
const text = extracted?.envelope?.text != null
? normalizeText(String(extracted.envelope.text))
: normalizeText(stripInvisibleEnvelope(rawText || visibleText));
const itemRoot = findMessageItemRoot(element);
const meta = extractMessageMeta(itemRoot, element, visibleText);
return {
id: getMessageId(itemRoot, index),
sender: meta.sender,
timestamp: meta.timestamp,
mergeKey: buildAvatarMergeKey({ sender: meta.sender, text, visibleText, rawText }),
avatarSource: extractMessageAvatarSource(itemRoot, element)
};
}
function getLiveAvatarEntryFingerprint(entry) {
if (!entry || typeof entry !== "object") return "";
if (entry.id && !/^message-\d+$/.test(entry.id)) {
return `id:${entry.id}`;
}
const senderKey = normalizeSenderKey(entry.sender || "");
const timestampKey = normalizeSpace(entry.timestamp || "");
return [senderKey, timestampKey, entry.mergeKey || ""].filter(Boolean).join("\n@@\n");
}
function buildAvatarMergeKey(entry) {
const senderKey = normalizeSenderKey(entry?.sender || "");
const text = normalizeText(
entry?.text || entry?.visibleText || stripInvisibleEnvelope(entry?.rawText || "")
);
const textKey = normalizeSpace(text).slice(0, 500);
return [senderKey, textKey].filter(Boolean).join("\n@@\n");
}
function mergeEntriesWithLiveAvatars(entries, liveEntries) {
if (!Array.isArray(entries) || !entries.length || !Array.isArray(liveEntries) || !liveEntries.length) return;
if (entries.length === liveEntries.length) {
for (let i = 0; i < entries.length; i += 1) {
if (liveEntries[i]?.avatarSource) {
addAvatarSourceToEntry(entries[i], liveEntries[i].avatarSource);
}
}
}
const usedLiveIndexes = new Set();
let cursor = 0;
for (const entry of entries) {
let matchedIndex = -1;
for (let i = cursor; i < liveEntries.length; i += 1) {
if (usedLiveIndexes.has(i)) continue;
if (!isAvatarEntryMatch(entry, liveEntries[i])) continue;
matchedIndex = i;
break;
}
if (matchedIndex < 0) continue;
usedLiveIndexes.add(matchedIndex);
cursor = matchedIndex + 1;
if (liveEntries[matchedIndex]?.avatarSource) {
addAvatarSourceToEntry(entry, liveEntries[matchedIndex].avatarSource);
}
}
applySenderOrderedAvatarFallback(entries, liveEntries, usedLiveIndexes);
}
function applySenderOrderedAvatarFallback(entries, liveEntries, usedLiveIndexes = new Set()) {
if (!Array.isArray(entries) || !Array.isArray(liveEntries)) return;
let cursor = 0;
for (const entry of entries) {
if (normalizeAssetSource(entry?.avatarSource || "")) continue;
const targetSender = normalizeSenderKey(entry?.sender || "");
if (!targetSender) continue;
for (let i = cursor; i < liveEntries.length; i += 1) {
if (usedLiveIndexes.has(i)) continue;
const liveEntry = liveEntries[i];
if (!liveEntry?.avatarSource) continue;
if (normalizeSenderKey(liveEntry.sender || "") !== targetSender) continue;
addAvatarSourceToEntry(entry, liveEntry.avatarSource);
usedLiveIndexes.add(i);
cursor = i + 1;
break;
}
}
}
function isAvatarEntryMatch(entry, liveEntry) {
if (!entry || !liveEntry) return false;
const targetKey = buildAvatarMergeKey(entry);
const liveKey = buildAvatarMergeKey(liveEntry);
const targetSender = normalizeSenderKey(entry.sender || "");
const liveSender = normalizeSenderKey(liveEntry.sender || "");
const senderMatches = !!targetSender && !!liveSender && targetSender === liveSender;
if (targetKey && liveKey && targetKey === liveKey) {
return !targetSender || !liveSender || senderMatches;
}
if (senderMatches && targetKey && liveKey) {
return targetKey.includes(liveKey) || liveKey.includes(targetKey);
}
if (!targetKey && senderMatches) return true;
return false;
}
function extractMessageAvatarSource(itemRoot, textElement) {
if (!(itemRoot instanceof Element)) return "";
const searchRoots = getAvatarSearchRoots(itemRoot);
const directAvatarNode = findDirectAvatarNode(searchRoots, textElement);
const directAvatarSource = extractElementImageSource(directAvatarNode);
if (directAvatarSource) return directAvatarSource;
const nearbyAvatarNode = findNearbyAvatarNode(textElement);
const nearbyAvatarSource = extractElementImageSource(nearbyAvatarNode);
if (nearbyAvatarSource) return nearbyAvatarSource;
const candidates = [];
const nodes = searchRoots.flatMap((root) => [...root.querySelectorAll("*")]);
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue;
if (node === textElement || textElement?.contains?.(node)) continue;
if (!isVisible(node)) continue;
const source = extractElementImageSource(node);
if (!source) continue;
const score = scoreAvatarCandidate(node);
if (score <= 0) continue;
candidates.push({ source, score });
}
candidates.sort((left, right) => right.score - left.score);
return candidates[0]?.source || "";
}
function findNearbyAvatarNode(textElement) {
if (!(textElement instanceof HTMLElement)) return null;
const searchRoot = findNearbyAvatarSearchRoot(textElement);
if (!(searchRoot instanceof HTMLElement)) return null;
const textRect = textElement.getBoundingClientRect();
if (!textRect || textRect.width <= 0 || textRect.height <= 0) return null;
const avatarSelectors = [
'img[alt="avatar"]',
'img[alt*="avatar" i]',
'.MuiAvatar-root img',
'[class*="Avatar"] img',
'[class*="avatar"] img',
'[data-testid*="avatar" i] img',
'[aria-label*="avatar" i] img'
];
const candidates = [];
for (const selector of avatarSelectors) {
searchRoot.querySelectorAll(selector).forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return;
const score = scoreNearbyAvatarCandidate(textRect, rect, node);
if (!Number.isFinite(score)) return;
candidates.push({ node, score });
});
}
candidates.sort((left, right) => left.score - right.score);
return candidates[0]?.node || null;
}
function findNearbyAvatarSearchRoot(textElement) {
if (!(textElement instanceof HTMLElement)) return null;
return textElement.closest(".MuiDrawer-paper")
|| textElement.closest('[role="presentation"]')
|| textElement.closest(MESSAGE_SCOPE_SELECTOR)
|| document.body;
}
function scoreNearbyAvatarCandidate(textRect, avatarRect, node) {
const textCenterY = textRect.top + (textRect.height / 2);
const avatarCenterY = avatarRect.top + (avatarRect.height / 2);
const verticalDelta = Math.abs(textCenterY - avatarCenterY);
if (verticalDelta > Math.max(72, textRect.height * 2.4)) return Number.POSITIVE_INFINITY;
const leftGap = textRect.left - avatarRect.right;
if (leftGap < -12) return Number.POSITIVE_INFINITY;
if (leftGap > 220) return Number.POSITIVE_INFINITY;
let score = verticalDelta + Math.max(0, leftGap) * 0.18;
const width = avatarRect.width;
const height = avatarRect.height;
const sizeDelta = Math.abs(width - 40) + Math.abs(height - 40);
score += sizeDelta * 0.1;
const tokens = [
node.getAttribute("alt") || "",
node.className || "",
node.getAttribute("aria-label") || "",
node.getAttribute("data-testid") || ""
].join(" ").toLowerCase();
if (/avatar/.test(tokens)) score -= 12;
if (/muiavatar/.test(tokens)) score -= 8;
return score;
}
function getAvatarSearchRoots(itemRoot) {
if (!(itemRoot instanceof HTMLElement)) return [];
const roots = [];
const seen = new Set();
let current = itemRoot;
for (let depth = 0; depth < 4 && current instanceof HTMLElement; depth += 1) {
if (!seen.has(current)) {
seen.add(current);
roots.push(current);
}
const parent = current.parentElement;
if (!(parent instanceof HTMLElement)) break;
if (parent.matches?.(MESSAGE_SCOPE_SELECTOR)) break;
const textCount = parent.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
if (textCount > 2) break;
current = parent;
}
return roots;
}
function findDirectAvatarNode(searchRoots, textElement) {
if (!Array.isArray(searchRoots) || !searchRoots.length) return null;
const selectors = [
'img[alt="avatar"]',
'img[alt*="avatar" i]',
'.MuiAvatar-root img',
'[class*="Avatar"] img',
'[class*="avatar"] img',
'[data-testid*="avatar" i] img',
'[aria-label*="avatar" i] img'
];
for (const root of searchRoots) {
for (const selector of selectors) {
const candidates = [...root.querySelectorAll(selector)];
const matched = candidates.find((node) =>
node instanceof HTMLElement &&
node !== textElement &&
!textElement?.contains?.(node)
);
if (matched instanceof HTMLElement) {
return matched;
}
}
}
return null;
}
function extractElementImageSource(node) {
if (!(node instanceof HTMLElement)) return "";
if (node instanceof HTMLImageElement) {
const currentSource = normalizeAssetSource(node.currentSrc || "");
if (currentSource) return currentSource;
const attrSource = normalizeAssetSource(node.getAttribute("src") || node.src || "");
if (attrSource) return attrSource;
}
for (const attrName of ["data-src", "data-original", "data-image", "src"]) {
const attrSource = normalizeAssetSource(node.getAttribute(attrName) || "");
if (attrSource) return attrSource;
}
const inlineBackground = extractCssUrls(node.style?.backgroundImage || "")[0] || "";
if (inlineBackground) return inlineBackground;
const computedBackground = extractCssUrls(getComputedStyle(node).backgroundImage || "")[0] || "";
if (computedBackground) return computedBackground;
return "";
}
function scoreAvatarCandidate(node) {
if (!(node instanceof HTMLElement)) return 0;
const tokens = [
node.className || "",
node.getAttribute("alt") || "",
node.getAttribute("aria-label") || "",
node.getAttribute("data-testid") || "",
node.getAttribute("role") || ""
].join(" ").toLowerCase();
let score = 0;
if (node instanceof HTMLImageElement) score += 7;
if (/avatar|icon|portrait|character|profile|user|face/.test(tokens)) score += 8;
if (/muiavatar/.test(tokens)) score += 6;
const rect = node.getBoundingClientRect?.();
const width = Number(rect?.width) || Number(node.getAttribute("width")) || 0;
const height = Number(rect?.height) || Number(node.getAttribute("height")) || 0;
if (width > 0 && height > 0) {
const maxSize = Math.max(width, height);
const minSize = Math.min(width, height);
if (maxSize <= 96) score += 4;
if (maxSize >= 24 && minSize >= 24) score += 2;
if (Math.abs(width - height) <= 18) score += 2;
if (maxSize >= 180) score -= 8;
}
if (node.childElementCount > 6) score -= 2;
return score;
}
function normalizeSenderKey(value) {
return normalizeSpace(value).toLowerCase();
}
function addAvatarSourceToEntry(entry, source) {
if (!entry || typeof entry !== "object") return;
const normalized = normalizeAssetSource(source);
if (!normalized) return;
entry.avatarSource = normalized;
entry.assetSources = mergeUniqueStrings(entry.assetSources || [], [normalized]);
}
function mergeUniqueStrings(existing, extras) {
const out = [];
const seen = new Set();
for (const value of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(extras) ? extras : [])]) {
const normalized = typeof value === "string" ? value : "";
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
function scoreSenderCandidate(node, text) {
const className = `${node.className || ""} ${node.getAttribute("aria-label") || ""}`.toLowerCase();
let score = 0;
if (/subtitle|primary|author|sender|name|user|character/.test(className)) score += 4;
if (/caption|time|date|meta/.test(className)) score -= 3;
if (/^H[1-6]$/.test(node.tagName)) score += 3;
if (node.tagName === "STRONG" || node.tagName === "B") score += 2;
if (text.length >= 1 && text.length <= 40) score += 1;
if (!/\d{1,2}:\d{2}/.test(text)) score += 1;
return score;
}
function looksLikeTimestamp(value) {
const text = normalizeSpace(value);
if (!text) return false;
return /^(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM|am|pm|오전|오후)?|\d{4}[./-]\d{1,2}[./-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)$/.test(text);
}
function collectAssetSourcesFromHtml(html) {
if (!html) return [];
const container = document.createElement("div");
container.innerHTML = html;
const seen = new Set();
const out = [];
const addSource = (value) => {
const normalized = normalizeAssetSource(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
out.push(normalized);
};
const nodes = [container, ...container.querySelectorAll("*")];
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue;
if (node instanceof HTMLImageElement) {
addSource(node.getAttribute("src") || node.src || "");
}
const backgroundImage = node.style?.backgroundImage || "";
extractCssUrls(backgroundImage).forEach(addSource);
}
return out;
}
async function buildAssetBundle(entries) {
const sources = [];
const seen = new Set();
for (const entry of entries) {
for (const source of entry.assetSources) {
if (seen.has(source)) continue;
seen.add(source);
sources.push(source);
}
}
const assets = await Promise.all(sources.map((source, index) => resolveAsset(source, index)));
return assets;
}
async function resolveAsset(source, index) {
let bytes = null;
let mimeType = "";
let error = "";
let included = false;
if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(source)) {
const parsed = parseDataUrl(source);
if (parsed) {
bytes = parsed.bytes;
mimeType = parsed.mimeType;
included = true;
} else {
error = "invalid-data-url";
}
} else {
try {
const response = await fetch(source);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
bytes = new Uint8Array(await blob.arrayBuffer());
mimeType = blob.type || guessMimeTypeFromUrl(source);
included = true;
} catch (fetchError) {
error = fetchError?.message || String(fetchError);
}
}
const fileName = included
? `images/asset-${String(index + 1).padStart(3, "0")}.${guessFileExtension(mimeType, source)}`
: "";
return {
index: index + 1,
source,
fileName,
included,
renderUrl: fileName || source,
mimeType: mimeType || guessMimeTypeFromUrl(source),
size: bytes?.length || 0,
error,
bytes
};
}
function rewriteEntryHtmlForPackage(html, assetMap) {
const container = document.createElement("div");
container.className = "ccf-render-root";
container.innerHTML = html || "";
rewriteAssetSourcesInTree(container, assetMap);
trimBoundaryBlankLinesInTree(container);
return container.innerHTML;
}
function trimBoundaryBlankLinesInTree(root) {
if (!(root instanceof HTMLElement)) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const textNodes = [];
let current = walker.nextNode();
while (current) {
textNodes.push(current);
current = walker.nextNode();
}
if (!textNodes.length) return;
const first = textNodes.find((node) => typeof node.textContent === "string" && node.textContent.length);
const last = [...textNodes].reverse().find((node) => typeof node.textContent === "string" && node.textContent.length);
if (first) {
first.textContent = trimLeadingBlankLines(first.textContent);
}
if (last) {
last.textContent = trimTrailingBlankLines(last.textContent);
}
}
function trimLeadingBlankLines(value) {
return String(value || "").replace(/^(?:[ \t\f\v\u00a0]*\n)+[ \t\f\v\u00a0]*/, "");
}
function trimTrailingBlankLines(value) {
return String(value || "").replace(/[ \t\f\v\u00a0]*(?:\n[ \t\f\v\u00a0]*)+$/, "");
}
function rewriteAssetSourcesInTree(root, assetMap) {
const nodes = [root, ...root.querySelectorAll("*")];
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue;
if (node instanceof HTMLImageElement) {
const source = normalizeAssetSource(node.getAttribute("src") || node.src || "");
const mapped = source ? assetMap.get(source) : null;
if (mapped?.renderUrl) {
node.setAttribute("src", mapped.renderUrl);
}
}
const backgroundImage = node.style?.backgroundImage || "";
if (backgroundImage) {
node.style.backgroundImage = rewriteCssUrls(backgroundImage, assetMap);
}
}
}
function buildLogJson({ roomTitle, exportedAt, entries, assets }) {
const payload = {
version: PACKAGE_VERSION,
exportedAt: exportedAt.toISOString(),
room: {
title: roomTitle,
url: location.href,
path: location.pathname
},
assets: assets.map((asset) => ({
index: asset.index,
source: asset.source,
fileName: asset.fileName,
included: asset.included,
renderUrl: asset.renderUrl,
mimeType: asset.mimeType,
size: asset.size,
error: asset.error || ""
})),
messages: entries.map((entry) => ({
index: entry.index,
id: entry.id,
sender: entry.sender,
avatarSource: entry.avatarSource || "",
timestamp: entry.timestamp,
metaTexts: entry.metaTexts,
channel: entry.channel || "",
text: entry.text,
visibleText: entry.visibleText,
rawText: entry.rawText,
baseColor: entry.baseColor || "",
formatEnvelopeVersion: entry.formatEnvelopeVersion,
formatRuns: entry.formatRuns,
alignRuns: entry.alignRuns,
blockStyle: entry.blockStyle,
assetSources: entry.assetSources,
html: entry.packageHtml
}))
};
return JSON.stringify(payload, null, 2);
}
const PACKAGE_THEME_STORAGE_KEY = "ccf-theme-switcher-settings-v1";
const PACKAGE_THEME_MODE_DEFAULT = "default";
const PACKAGE_THEME_MODE_LIGHT = "light";
const PACKAGE_THEME_MODE_CUSTOM = "custom";
const PACKAGE_THEME_SAVED_MODE_PREFIX = "saved:";
const PACKAGE_THEME_DEFAULT_FALLBACK = Object.freeze({
bg: "#202020",
appbar: "#212121",
paper: "#2a2a2a",
border: "#444444",
text: "#ffffff",
inputBg: "#202020"
});
const PACKAGE_THEME_LIGHT_PRESET = Object.freeze({
bg: "#f1f1f1",
appbar: "#dddddd",
paper: "#fbfbfb",
border: "#b9b9b9",
text: "#2f2f2f",
inputBg: "#ffffff"
});
const PACKAGE_THEME_CUSTOM_FALLBACK = Object.freeze({
bg: "#151414",
appbar: "#22201f",
paper: "#1d1c1e",
border: "#413d3a",
text: "#f4f0eb",
inputBg: "#1a191b"
});
const PACKAGE_THEME_FIELD_DEFS = Object.freeze([
{ key: "bg", label: "\uBC30\uACBD" },
{ key: "appbar", label: "\uC0C1\uB2E8 \uBC14" },
{ key: "paper", label: "\uD328\uB110" },
{ key: "border", label: "\uD14C\uB450\uB9AC" },
{ key: "text", label: "\uD14D\uC2A4\uD2B8" }
]);
function getPackageThemeDefinition() {
const context = readActivePackageThemeContext() || readStoredPackageThemeContext();
return buildPackageThemeDefinition(context);
}
function readActivePackageThemeContext() {
const root = document.documentElement;
if (!(root instanceof HTMLElement)) return null;
const styles = getComputedStyle(root);
const rawTheme = {
bg: styles.getPropertyValue("--ccf-theme-bg"),
appbar: styles.getPropertyValue("--ccf-theme-appbar"),
paper: styles.getPropertyValue("--ccf-theme-paper"),
border: styles.getPropertyValue("--ccf-theme-border"),
text: styles.getPropertyValue("--ccf-theme-text"),
inputBg: styles.getPropertyValue("--ccf-theme-input-bg")
};
const hasLiveTheme = Object.values(rawTheme)
.map((value) => normalizeCssColor(value))
.filter(Boolean)
.length >= 4;
if (!hasLiveTheme) return null;
return {
mode: normalizePackageThemeMode(root.getAttribute("data-ccf-theme-mode") || ""),
theme: normalizePackageThemePalette(rawTheme, PACKAGE_THEME_DEFAULT_FALLBACK)
};
}
function readStoredPackageThemeContext() {
const state = readStoredPackageThemeState();
return {
mode: state.mode,
theme: resolvePackageThemePaletteForMode(state.mode, state),
savedThemes: state.savedThemes
};
}
function readStoredPackageThemeState() {
let raw = null;
try {
raw = JSON.parse(window.localStorage.getItem(PACKAGE_THEME_STORAGE_KEY) || "null");
} catch (error) {
raw = null;
}
const savedThemes = normalizePackageSavedThemes(raw?.savedThemes);
return {
hasThemeSwitcher: !!(raw && typeof raw === "object"),
mode: normalizePackageThemeMode(raw?.mode, savedThemes),
defaultTheme: normalizeOptionalPackageThemePalette(raw?.defaultTheme),
customTheme: normalizePackageThemePalette(
raw?.customTheme || raw?.theme || null,
PACKAGE_THEME_CUSTOM_FALLBACK
),
savedThemes
};
}
function normalizePackageSavedThemes(value) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const id = normalizeSpace(item?.id || "");
if (!id) return null;
return {
id,
name: normalizeSpace(item?.name || ""),
theme: normalizePackageThemePalette(item?.theme || null, PACKAGE_THEME_CUSTOM_FALLBACK)
};
})
.filter(Boolean);
}
function normalizePackageThemeMode(value, savedThemes = []) {
if (isPackageSavedThemeMode(value) && savedThemes.some((item) => makePackageSavedThemeMode(item.id) === value)) {
return value;
}
return [
PACKAGE_THEME_MODE_DEFAULT,
PACKAGE_THEME_MODE_LIGHT,
PACKAGE_THEME_MODE_CUSTOM
].includes(value) ? value : PACKAGE_THEME_MODE_DEFAULT;
}
function isPackageSavedThemeMode(value) {
return typeof value === "string" && value.startsWith(PACKAGE_THEME_SAVED_MODE_PREFIX);
}
function makePackageSavedThemeMode(id) {
return `${PACKAGE_THEME_SAVED_MODE_PREFIX}${id}`;
}
function normalizeOptionalPackageThemePalette(value) {
if (!value || typeof value !== "object") return null;
const normalized = normalizePackageThemePalette(value, PACKAGE_THEME_DEFAULT_FALLBACK);
return Object.values(normalized).some(Boolean) ? normalized : null;
}
function normalizePackageThemePalette(value, fallback = PACKAGE_THEME_DEFAULT_FALLBACK) {
const base = fallback || PACKAGE_THEME_DEFAULT_FALLBACK;
return {
bg: normalizeCssColor(value?.bg) || base.bg,
appbar: normalizeCssColor(value?.appbar) || base.appbar,
paper: normalizeCssColor(value?.paper) || base.paper,
border: normalizeCssColor(value?.border) || base.border,
text: normalizeCssColor(value?.text) || base.text,
inputBg: normalizeCssColor(value?.inputBg) || base.inputBg
};
}
function resolvePackageThemePaletteForMode(mode, state = readStoredPackageThemeState()) {
const savedTheme = state.savedThemes.find((item) => makePackageSavedThemeMode(item.id) === mode) || null;
if (savedTheme?.theme) {
return normalizePackageThemePalette(savedTheme.theme, PACKAGE_THEME_CUSTOM_FALLBACK);
}
if (mode === PACKAGE_THEME_MODE_LIGHT) {
return normalizePackageThemePalette(PACKAGE_THEME_LIGHT_PRESET, PACKAGE_THEME_LIGHT_PRESET);
}
if (mode === PACKAGE_THEME_MODE_CUSTOM) {
return normalizePackageThemePalette(state.customTheme, PACKAGE_THEME_CUSTOM_FALLBACK);
}
return normalizePackageThemePalette(state.defaultTheme, PACKAGE_THEME_DEFAULT_FALLBACK);
}
function getPackageThemeOptionModel(currentMode = "") {
const state = readStoredPackageThemeState();
const hasThemeSwitcher = state.hasThemeSwitcher;
const options = [
{
value: PACKAGE_THEME_MODE_DEFAULT,
label: hasThemeSwitcher ? "\uAE30\uBCF8" : "\uB2E4\uD06C \uBAA8\uB4DC"
},
{
value: PACKAGE_THEME_MODE_LIGHT,
label: hasThemeSwitcher ? "\uB77C\uC774\uD2B8" : "\uB77C\uC774\uD2B8 \uBAA8\uB4DC"
},
{
value: PACKAGE_THEME_MODE_CUSTOM,
label: hasThemeSwitcher ? "\uCEE4\uC2A4\uD140" : "\uCEE4\uC2A4\uD140 \uBAA8\uB4DC"
},
...(
hasThemeSwitcher
? state.savedThemes.map((item) => ({
value: makePackageSavedThemeMode(item.id),
label: item.name || "\uC800\uC7A5 \uD14C\uB9C8"
}))
: []
)
];
const selectedMode = options.some((option) => option.value === currentMode)
? currentMode
: (options.some((option) => option.value === state.mode) ? state.mode : options[0]?.value || PACKAGE_THEME_MODE_DEFAULT);
const definitions = options.reduce((out, option) => {
out[option.value] = buildPackageThemeDefinition({
mode: option.value,
theme: resolvePackageThemePaletteForMode(option.value, state),
savedThemes: state.savedThemes
});
return out;
}, {});
return {
selectedMode,
options,
definitions
};
}
function buildPackageThemeDefinition(context = {}) {
const theme = normalizePackageThemePalette(context.theme || null, PACKAGE_THEME_DEFAULT_FALLBACK);
const isLight = getPackageColorLuminance(theme.bg) >= 0.62;
const accent = mixPackageColors(theme.text, theme.appbar, isLight ? 0.38 : 0.22);
const accentContrast = pickPackageReadableText(accent);
const accentBorder = mixPackageColors(accent, theme.border, 0.34);
const chipBg = mixPackageColors(theme.appbar, theme.paper, 0.58);
const chipBorder = mixPackageColors(theme.border, theme.paper, 0.72);
const codeBg = mixPackageColors(theme.appbar, theme.inputBg, 0.62);
const codeBorder = mixPackageColors(theme.border, theme.paper, 0.8);
const shadowColor = withPackageColorAlpha("#000000", isLight ? 0.12 : 0.26);
const buttonShadow = withPackageColorAlpha("#000000", isLight ? 0.12 : 0.22);
return {
mode: isPackageSavedThemeMode(context.mode)
? String(context.mode)
: normalizePackageThemeMode(context.mode || "", normalizePackageSavedThemes(context.savedThemes)),
palette: theme,
colorScheme: isLight ? "light" : "dark",
vars: {
"--page-bg": theme.bg,
"--panel-bg": theme.paper,
"--panel-border": theme.border,
"--panel-shadow": `0 18px 48px ${shadowColor}`,
"--text-main": theme.text,
"--text-subtle": mixPackageColors(theme.text, theme.bg, 0.58),
"--accent": accent,
"--accent-contrast": accentContrast,
"--accent-border": accentBorder,
"--accent-shadow": `0 4px 12px ${buttonShadow}`,
"--chip-bg": chipBg,
"--chip-border": chipBorder,
"--chip-text": theme.text,
"--code-bg": codeBg,
"--code-border": codeBorder,
"--code-text": pickPackageReadableText(codeBg),
"--helper-bg": mixPackageColors(theme.paper, theme.bg, 0.22)
}
};
}
function buildPackageThemeCssText(themeDefinition) {
if (!themeDefinition?.vars) return "";
return Object.entries(themeDefinition.vars)
.map(([key, value]) => `${key}: ${value};`)
.join("\n ");
}
function applyPackageThemeVariables(target, themeDefinition) {
if (!(target instanceof HTMLElement) || !themeDefinition?.vars) return;
Object.entries(themeDefinition.vars).forEach(([key, value]) => {
target.style.setProperty(key, value);
});
}
function parsePackageColorChannels(value) {
const normalized = normalizeCssColor(value);
if (!normalized) return null;
const match = normalized.match(/^rgba?\(([^)]+)\)$/i);
if (!match) return null;
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length < 3) return null;
return {
r: clamp(Number(parts[0]), 0, 255),
g: clamp(Number(parts[1]), 0, 255),
b: clamp(Number(parts[2]), 0, 255),
a: parts.length >= 4 ? clamp(Number(parts[3]), 0, 1) : 1
};
}
function mixPackageColors(primary, secondary, amount = 0.5) {
const left = parsePackageColorChannels(primary);
const right = parsePackageColorChannels(secondary);
if (!left && !right) return normalizeCssColor(primary) || normalizeCssColor(secondary) || String(primary || secondary || "");
if (!left) return normalizeCssColor(secondary) || String(secondary || "");
if (!right) return normalizeCssColor(primary) || String(primary || "");
const ratio = clamp(Number(amount), 0, 1);
const inverse = 1 - ratio;
const red = Math.round((left.r * ratio) + (right.r * inverse));
const green = Math.round((left.g * ratio) + (right.g * inverse));
const blue = Math.round((left.b * ratio) + (right.b * inverse));
const alpha = (left.a * ratio) + (right.a * inverse);
return alpha >= 0.999
? `rgb(${red}, ${green}, ${blue})`
: `rgba(${red}, ${green}, ${blue}, ${alpha.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")})`;
}
function withPackageColorAlpha(value, alpha = 1) {
const channels = parsePackageColorChannels(value);
if (!channels) return String(value || "");
const nextAlpha = clamp(Number(alpha), 0, 1);
if (nextAlpha >= 0.999) {
return `rgb(${channels.r}, ${channels.g}, ${channels.b})`;
}
return `rgba(${channels.r}, ${channels.g}, ${channels.b}, ${nextAlpha.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")})`;
}
function getPackageColorLuminance(value) {
const channels = parsePackageColorChannels(value);
if (!channels) return 0;
return ((0.2126 * channels.r) + (0.7152 * channels.g) + (0.0722 * channels.b)) / 255;
}
function pickPackageReadableText(background) {
return getPackageColorLuminance(background) >= 0.56 ? "#202020" : "#ffffff";
}
function buildIndexHtml({
roomTitle,
exportedAt,
entries,
assets,
tistoryContentHtml = "",
themeDefinition = null,
themeOptionModel = null,
tistoryContentHtmlByMode = null
}) {
const fallbackTheme = themeDefinition || getPackageThemeDefinition();
const themeModel = themeOptionModel || getPackageThemeOptionModel(fallbackTheme.mode);
const theme = themeModel.definitions[themeModel.selectedMode] || fallbackTheme;
const themeCssText = buildPackageThemeCssText(theme);
const packageAssetMap = new Map((Array.isArray(assets) ? assets : []).map((asset) => [asset.source, asset]));
const tistoryHtmlMap = tistoryContentHtmlByMode && typeof tistoryContentHtmlByMode === "object"
? tistoryContentHtmlByMode
: { [themeModel.selectedMode]: tistoryContentHtml || "" };
const serializedThemeDefinitions = JSON.stringify(
Object.entries(themeModel.definitions).reduce((out, [mode, definition]) => {
out[mode] = {
colorScheme: definition.colorScheme,
vars: definition.vars,
palette: definition.palette
};
return out;
}, {})
).replace(/</g, "\\u003c");
const serializedTistoryHtmlMap = JSON.stringify(tistoryHtmlMap).replace(/</g, "\\u003c");
const serializedThemeFieldDefs = JSON.stringify(PACKAGE_THEME_FIELD_DEFS).replace(/</g, "\\u003c");
const serializedCustomFallbackPalette = JSON.stringify(PACKAGE_THEME_CUSTOM_FALLBACK).replace(/</g, "\\u003c");
const main = document.createElement("main");
main.className = "ccf-log-package-page";
const header = document.createElement("header");
header.className = "ccf-log-package-header";
const title = document.createElement("h1");
title.textContent = roomTitle;
header.appendChild(title);
const summary = document.createElement("p");
summary.className = "ccf-log-package-summary";
summary.textContent = `메시지 ${entries.length}개 · 이미지 자산 ${assets.filter((asset) => asset.included).length}개 · 내보낸 시각 ${formatDisplayDate(exportedAt)}`;
const actions = document.createElement("div");
actions.className = "ccf-log-package-actions";
const themeField = document.createElement("label");
themeField.className = "ccf-log-package-theme-field";
themeField.setAttribute("for", "ccf-theme-mode");
const themeLabel = document.createElement("span");
themeLabel.className = "ccf-log-package-theme-label";
themeLabel.textContent = "\uD14C\uB9C8";
themeField.appendChild(themeLabel);
const themeSelect = document.createElement("select");
themeSelect.className = "ccf-log-package-theme-select";
themeSelect.id = "ccf-theme-mode";
themeModel.options.forEach((optionDef) => {
const option = document.createElement("option");
option.value = optionDef.value;
option.textContent = optionDef.label;
if (optionDef.value === themeModel.selectedMode) {
option.selected = true;
}
themeSelect.appendChild(option);
});
themeField.appendChild(themeSelect);
actions.appendChild(themeField);
const copyButton = document.createElement("button");
copyButton.className = "ccf-log-package-copy-btn";
copyButton.type = "button";
copyButton.id = "ccf-tistory-copy-btn";
copyButton.textContent = "티스토리 HTML 복사";
copyButton.textContent = "티스토리 HTML 복사";
copyButton.textContent = "\uD2F0\uC2A4\uD1A0\uB9AC HTML \uBCF5\uC0AC";
actions.appendChild(copyButton);
const copyHint = document.createElement("p");
copyHint.className = "ccf-log-package-copy-hint";
copyHint.id = "ccf-tistory-copy-status";
copyHint.textContent = "선택한 테마 기준으로 티스토리 HTML 복사용 본문이 갱신됩니다.";
copyHint.textContent = "버튼을 누르면 티스토리 HTML 모드에 붙여넣을 본문 HTML이 복사됩니다.";
copyHint.textContent = "\uC120\uD0DD\uD55C \uD14C\uB9C8 \uAE30\uC900\uC73C\uB85C \uD2F0\uC2A4\uD1A0\uB9AC HTML \uBCF5\uC0AC\uC6A9 \uBCF8\uBB38\uC774 \uAC31\uC2E0\uB429\uB2C8\uB2E4.";
actions.appendChild(copyHint);
const customPanel = document.createElement("section");
customPanel.className = "ccf-log-package-custom-theme";
customPanel.id = "ccf-theme-custom-panel";
customPanel.hidden = themeModel.selectedMode !== PACKAGE_THEME_MODE_CUSTOM;
const customTitle = document.createElement("h2");
customTitle.className = "ccf-log-package-custom-theme-title";
customTitle.textContent = "\uCEE4\uC2A4\uD140 \uD14C\uB9C8 \uC0C9\uC0C1";
customPanel.appendChild(customTitle);
const customHint = document.createElement("p");
customHint.className = "ccf-log-package-custom-theme-hint";
customHint.textContent = "\uAC01 \uD56D\uBAA9 \uC0C9\uC0C1\uC744 \uBC14\uAFB8\uBA74 \uBBF8\uB9AC\uBCF4\uAE30\uC640 \uD2F0\uC2A4\uD1A0\uB9AC \uBCF5\uC0AC\uC6A9 HTML\uC5D0 \uBC14\uB85C \uBC18\uC601\uB429\uB2C8\uB2E4.";
customPanel.appendChild(customHint);
const customGrid = document.createElement("div");
customGrid.className = "ccf-log-package-custom-theme-grid";
PACKAGE_THEME_FIELD_DEFS.forEach((fieldDef) => {
const item = document.createElement("label");
item.className = "ccf-log-package-custom-theme-item";
const itemLabel = document.createElement("span");
itemLabel.className = "ccf-log-package-custom-theme-item-label";
itemLabel.textContent = fieldDef.label;
item.appendChild(itemLabel);
const controls = document.createElement("div");
controls.className = "ccf-log-package-custom-theme-controls";
const colorInput = document.createElement("input");
colorInput.className = "ccf-log-package-custom-theme-color";
colorInput.type = "color";
colorInput.id = `ccf-theme-custom-${fieldDef.key}`;
colorInput.setAttribute("data-theme-key", fieldDef.key);
controls.appendChild(colorInput);
const codeInput = document.createElement("input");
codeInput.className = "ccf-log-package-custom-theme-code";
codeInput.type = "text";
codeInput.inputMode = "text";
codeInput.autocomplete = "off";
codeInput.spellcheck = false;
codeInput.id = `ccf-theme-custom-${fieldDef.key}-text`;
codeInput.setAttribute("data-theme-key-text", fieldDef.key);
controls.appendChild(codeInput);
item.appendChild(controls);
customGrid.appendChild(item);
});
customPanel.appendChild(customGrid);
header.appendChild(actions);
header.appendChild(customPanel);
main.appendChild(header);
const list = document.createElement("section");
list.className = "ccf-log-entry-list";
for (const entry of entries) {
const article = document.createElement("article");
article.className = "ccf-log-entry";
const avatarUrl = resolvePackageRenderableImageUrl(entry.avatarSource, packageAssetMap);
const mainRow = document.createElement("div");
mainRow.className = "ccf-log-entry-main";
if (avatarUrl) {
const avatar = document.createElement("img");
avatar.className = "ccf-log-entry-avatar";
avatar.src = avatarUrl;
avatar.alt = entry.sender ? `${entry.sender} avatar` : "avatar";
avatar.loading = "eager";
avatar.decoding = "sync";
mainRow.appendChild(avatar);
}
const content = document.createElement("div");
content.className = "ccf-log-entry-content";
const meta = document.createElement("div");
meta.className = "ccf-log-entry-header";
const metaMain = document.createElement("div");
metaMain.className = "ccf-log-entry-meta-main";
const metaAux = document.createElement("div");
metaAux.className = "ccf-log-entry-meta-aux";
if (entry.sender) {
const sender = document.createElement("span");
sender.className = "ccf-log-entry-sender";
sender.textContent = entry.sender;
if (entry.baseColor) {
sender.style.color = entry.baseColor;
}
metaMain.appendChild(sender);
}
if (entry.timestamp) {
const timestamp = document.createElement("span");
timestamp.className = "ccf-log-entry-timestamp";
timestamp.textContent = entry.timestamp;
metaMain.appendChild(timestamp);
}
if (Number.isFinite(entry.index)) {
const indexTag = document.createElement("span");
indexTag.className = "ccf-log-entry-index";
indexTag.textContent = `#${String(entry.index).padStart(3, "0")}`;
metaAux.appendChild(indexTag);
}
if (entry.channel) {
const channel = document.createElement("span");
channel.className = "ccf-log-entry-channel";
channel.textContent = entry.channel;
metaAux.appendChild(channel);
}
if (metaMain.childNodes.length) {
meta.appendChild(metaMain);
}
if (metaAux.childNodes.length) {
meta.appendChild(metaAux);
}
if (meta.childNodes.length) {
content.appendChild(meta);
}
const body = document.createElement("div");
body.className = "ccf-log-entry-body ccf-render-root";
if (entry.packageHtml) {
body.innerHTML = entry.packageHtml;
} else {
body.textContent = trimTrailingBlankLines(trimLeadingBlankLines(entry.text || entry.visibleText || ""));
}
content.appendChild(body);
mainRow.appendChild(content);
article.appendChild(mainRow);
list.appendChild(article);
}
main.appendChild(list);
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(roomTitle)} - CCF Log Package</title>
<style>
:root {
color-scheme: ${theme.colorScheme};
${themeCssText}
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--page-bg);
color: var(--text-main);
font-family: "Segoe UI", "Noto Sans KR", sans-serif;
line-height: 1.6;
}
body {
padding: 28px 18px 56px;
}
.ccf-log-package-page {
max-width: 1080px;
margin: 0 auto;
}
.ccf-log-package-header {
margin-bottom: 18px;
}
.ccf-log-package-header h1 {
margin: 0;
font-size: clamp(24px, 3vw, 36px);
line-height: 1.15;
}
.ccf-log-package-actions {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.ccf-log-package-theme-field {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ccf-log-package-theme-label {
color: var(--text-subtle);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.ccf-log-package-theme-select {
min-width: 160px;
border: 1px solid var(--panel-border);
border-radius: 0;
background: var(--panel-bg);
color: var(--text-main);
padding: 8px 14px;
font: inherit;
font-size: 13px;
line-height: 1.3;
outline: none;
cursor: pointer;
}
.ccf-log-package-theme-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px ${withPackageColorAlpha(theme.vars["--accent"] || "#000000", 0.16)};
}
.ccf-log-package-custom-theme {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--panel-border);
border-radius: 0;
background: var(--helper-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ccf-log-package-custom-theme[hidden] {
display: none;
}
.ccf-log-package-custom-theme-title {
margin: 0;
font-size: 15px;
line-height: 1.3;
color: var(--text-main);
}
.ccf-log-package-custom-theme-hint {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-subtle);
}
.ccf-log-package-custom-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.ccf-log-package-custom-theme-item {
display: grid;
gap: 8px;
min-width: 0;
}
.ccf-log-package-custom-theme-item-label {
font-size: 12px;
font-weight: 700;
color: var(--text-subtle);
}
.ccf-log-package-custom-theme-controls {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ccf-log-package-custom-theme-color {
flex: 0 0 44px;
width: 44px;
height: 36px;
padding: 0;
border: 1px solid var(--panel-border);
border-radius: 0;
background: var(--panel-bg);
cursor: pointer;
}
.ccf-log-package-custom-theme-code {
flex: 1 1 auto;
min-width: 0;
border: 1px solid var(--panel-border);
border-radius: 0;
background: var(--panel-bg);
color: var(--text-main);
padding: 9px 12px;
font: inherit;
font-size: 13px;
line-height: 1.2;
outline: none;
}
.ccf-log-package-custom-theme-code:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px ${withPackageColorAlpha(theme.vars["--accent"] || "#000000", 0.16)};
}
.ccf-log-package-copy-btn {
appearance: none;
border: 1px solid var(--accent-border);
border-radius: 0;
padding: 10px 16px;
background: var(--accent);
color: #151414;
font: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
text-shadow: none;
cursor: pointer;
box-shadow: var(--accent-shadow);
}
.ccf-log-package-copy-btn:hover {
filter: brightness(1.04);
}
.ccf-log-package-copy-hint {
margin: 0;
color: var(--text-subtle);
font-size: 13px;
}
.ccf-log-entry-list {
display: grid;
gap: 14px;
}
.ccf-log-entry {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 0;
box-shadow: var(--panel-shadow);
padding: 16px 18px;
}
.ccf-log-entry-main {
display: flex;
align-items: flex-start;
gap: 16px;
}
.ccf-log-entry-content {
min-width: 0;
flex: 1 1 auto;
}
.ccf-log-entry-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0 0 2px;
font-size: 12px;
line-height: 1.4;
color: var(--text-subtle);
}
.ccf-log-entry-meta-main {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
flex: 1 1 auto;
}
.ccf-log-entry-avatar {
width: 40px;
min-width: 40px;
max-width: 40px;
height: 40px;
min-height: 40px;
max-height: 40px;
flex: 0 0 40px;
flex-shrink: 0;
align-self: flex-start;
display: block;
object-fit: cover;
border-radius: 0;
background: var(--helper-bg);
border: 1px solid var(--panel-border);
}
.ccf-log-entry-meta-aux {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 6px;
margin-left: auto;
flex: 0 0 auto;
}
.ccf-log-entry-index {
color: var(--accent);
font-weight: 700;
}
.ccf-log-entry-channel {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--chip-border);
border-radius: 0;
background: var(--chip-bg);
color: var(--chip-text);
font-weight: 700;
}
.ccf-log-entry-sender {
font-size: 14px;
font-weight: 700;
line-height: 1.4;
color: var(--text-main);
}
.ccf-log-entry-timestamp {
line-height: 1.4;
}
.ccf-log-entry-body {
margin: 0;
padding: 0;
font-size: 14px;
line-height: 1.6;
}
.ccf-log-entry-body > * {
margin-top: 0;
margin-bottom: 0;
}
.ccf-log-entry-body p {
margin: 0;
}
.ccf-render-root {
white-space: pre-wrap;
word-break: break-word;
}
.ccf-render-root .ccf-frag {
white-space: pre-wrap;
}
.ccf-render-root .ccf-line {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.ccf-render-root .ccf-ruby-frag {
position: relative;
display: inline-block;
vertical-align: baseline;
white-space: pre-wrap;
overflow: visible;
}
.ccf-render-root .ccf-ruby-frag::before {
content: attr(data-ruby);
position: absolute;
bottom: calc(100% - 0.08em);
left: 50%;
transform: translateX(-50%);
font-size: 0.62em;
line-height: 1;
white-space: nowrap;
color: currentColor;
pointer-events: none;
}
.ccf-render-root .ccf-tooltip-frag {
position: relative;
display: inline-block;
vertical-align: baseline;
white-space: pre-wrap;
overflow: visible;
cursor: help;
border-bottom: 1px dashed currentColor;
padding-bottom: 0.02em;
}
.ccf-render-root .ccf-tooltip-frag::before,
.ccf-render-root .ccf-tooltip-frag::after {
position: absolute;
left: calc(100% + 6px);
opacity: 0;
visibility: hidden;
transition: opacity 120ms ease;
pointer-events: none;
z-index: 2;
}
.ccf-render-root .ccf-tooltip-frag::before {
content: "";
left: calc(100% + 12px);
bottom: calc(100% + 2px);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid var(--code-bg);
}
.ccf-render-root .ccf-tooltip-frag::after {
content: attr(data-tooltip);
bottom: calc(100% + 8px);
min-width: 40px;
max-width: min(320px, calc(100vw - 32px));
padding: 7px 10px;
border-radius: 0;
background: var(--code-bg);
color: var(--code-text);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
font-size: 12px;
line-height: 1.35;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ccf-render-root .ccf-tooltip-frag:hover::before,
.ccf-render-root .ccf-tooltip-frag:hover::after {
opacity: 1;
visibility: visible;
}
.ccf-render-root .ccf-code-frag {
font-family: Consolas, "Courier New", monospace;
font-size: 0.92em;
line-height: 1.5;
color: var(--code-text);
background: var(--code-bg);
border: 1px solid var(--code-border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
white-space: pre-wrap;
overflow-wrap: anywhere;
box-sizing: border-box;
}
.ccf-render-root .ccf-code-frag.is-inline {
display: inline-block;
padding: 0.08em 0.45em 0.12em;
vertical-align: baseline;
}
.ccf-render-root .ccf-code-frag.is-block {
display: block;
width: 100%;
margin: 6px 0;
padding: 10px 12px;
}
.ccf-render-root .ccf-image-frag {
position: relative;
display: inline-block;
width: 100%;
margin: 4px 0;
vertical-align: top;
}
.ccf-render-root .ccf-image {
display: block;
width: auto;
max-width: min(100%, 420px);
height: auto;
border: 0;
border-radius: 0;
box-sizing: border-box;
margin: 0 auto;
}
.ccf-render-root .ccf-image-token {
display: inline-block;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
font-size: 0;
line-height: 0;
white-space: pre;
pointer-events: none;
user-select: none;
}
</style>
<script>
(function () {
var themeDefinitions = ${serializedThemeDefinitions};
var tistoryHtmlByMode = ${serializedTistoryHtmlMap};
var themeFieldDefs = ${serializedThemeFieldDefs};
var customFallbackPalette = ${serializedCustomFallbackPalette};
var initialThemeMode = ${JSON.stringify(themeModel.selectedMode)};
var currentThemeMode = initialThemeMode;
var pendingSourceUpdateTimer = 0;
var customPalette = normalizeThemePalette(
(themeDefinitions.custom && themeDefinitions.custom.palette) || customFallbackPalette,
customFallbackPalette
);
function clampNumber(value, min, max) {
var numeric = Number(value);
if (!Number.isFinite(numeric)) return min;
if (numeric < min) return min;
if (numeric > max) return max;
return numeric;
}
function normalizeCssColorValue(value) {
if (value == null) return '';
var probe = document.createElement('span');
probe.style.color = '';
probe.style.color = String(value).trim();
return probe.style.color || '';
}
function normalizeHexColor(value) {
var match = String(value || '').trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!match) return '';
var hex = match[1];
if (hex.length === 3) {
hex = hex.split('').map(function (char) { return char + char; }).join('');
}
return ('#' + hex).toUpperCase();
}
function colorToHex(value) {
var direct = normalizeHexColor(value);
if (direct) return direct;
var normalized = normalizeCssColorValue(value);
var match = normalized.match(/^rgba?\(([^)]+)\)$/i);
if (!match) return '';
var parts = match[1].split(',').map(function (part) { return part.trim(); });
if (parts.length < 3) return '';
return '#' + [0, 1, 2]
.map(function (index) {
return clampNumber(Number(parts[index]), 0, 255)
.toString(16)
.padStart(2, '0');
})
.join('')
.toUpperCase();
}
function parseColorChannels(value) {
var normalized = normalizeCssColorValue(value);
if (!normalized) return null;
var match = normalized.match(/^rgba?\(([^)]+)\)$/i);
if (!match) return null;
var parts = match[1].split(',').map(function (part) { return part.trim(); });
if (parts.length < 3) return null;
return {
r: clampNumber(Number(parts[0]), 0, 255),
g: clampNumber(Number(parts[1]), 0, 255),
b: clampNumber(Number(parts[2]), 0, 255),
a: parts.length >= 4 ? clampNumber(Number(parts[3]), 0, 1) : 1
};
}
function mixColors(primary, secondary, amount) {
var left = parseColorChannels(primary);
var right = parseColorChannels(secondary);
if (!left && !right) return normalizeCssColorValue(primary) || normalizeCssColorValue(secondary) || String(primary || secondary || '');
if (!left) return normalizeCssColorValue(secondary) || String(secondary || '');
if (!right) return normalizeCssColorValue(primary) || String(primary || '');
var ratio = clampNumber(amount, 0, 1);
var inverse = 1 - ratio;
var red = Math.round((left.r * ratio) + (right.r * inverse));
var green = Math.round((left.g * ratio) + (right.g * inverse));
var blue = Math.round((left.b * ratio) + (right.b * inverse));
var alpha = (left.a * ratio) + (right.a * inverse);
return alpha >= 0.999
? 'rgb(' + red + ', ' + green + ', ' + blue + ')'
: 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + alpha.toFixed(3).replace(/0+$/g, '').replace(/\.$/, '') + ')';
}
function withColorAlpha(value, alpha) {
var channels = parseColorChannels(value);
if (!channels) return String(value || '');
var nextAlpha = clampNumber(alpha, 0, 1);
if (nextAlpha >= 0.999) {
return 'rgb(' + channels.r + ', ' + channels.g + ', ' + channels.b + ')';
}
return 'rgba(' + channels.r + ', ' + channels.g + ', ' + channels.b + ', ' + nextAlpha.toFixed(3).replace(/0+$/g, '').replace(/\.$/, '') + ')';
}
function getColorLuminance(value) {
var channels = parseColorChannels(value);
if (!channels) return 0;
return ((0.2126 * channels.r) + (0.7152 * channels.g) + (0.0722 * channels.b)) / 255;
}
function pickReadableText(value) {
return getColorLuminance(value) >= 0.56 ? '#202020' : '#FFFFFF';
}
function normalizeThemePalette(palette, fallback) {
var base = fallback || customFallbackPalette;
return {
bg: colorToHex(palette && palette.bg) || colorToHex(base.bg) || '#151414',
appbar: colorToHex(palette && palette.appbar) || colorToHex(base.appbar) || '#22201F',
paper: colorToHex(palette && palette.paper) || colorToHex(base.paper) || '#1D1C1E',
border: colorToHex(palette && palette.border) || colorToHex(base.border) || '#413D3A',
text: colorToHex(palette && palette.text) || colorToHex(base.text) || '#F4F0EB',
inputBg: colorToHex(palette && palette.inputBg) || colorToHex(base.inputBg) || '#1A191B'
};
}
function buildThemeDefinitionFromPalette(mode, palette) {
var normalizedPalette = normalizeThemePalette(palette, customFallbackPalette);
var isLight = getColorLuminance(normalizedPalette.bg) >= 0.62;
var accent = mixColors(normalizedPalette.text, normalizedPalette.appbar, isLight ? 0.38 : 0.22);
var accentContrast = pickReadableText(accent);
var accentBorder = mixColors(accent, normalizedPalette.border, 0.34);
var chipBg = mixColors(normalizedPalette.appbar, normalizedPalette.paper, 0.58);
var chipBorder = mixColors(normalizedPalette.border, normalizedPalette.paper, 0.72);
var codeBg = mixColors(normalizedPalette.appbar, normalizedPalette.inputBg, 0.62);
var codeBorder = mixColors(normalizedPalette.border, normalizedPalette.paper, 0.8);
var shadowColor = withColorAlpha('#000000', isLight ? 0.12 : 0.26);
var buttonShadow = withColorAlpha('#000000', isLight ? 0.12 : 0.22);
return {
mode: mode,
palette: normalizedPalette,
colorScheme: isLight ? 'light' : 'dark',
vars: {
'--page-bg': normalizedPalette.bg,
'--panel-bg': normalizedPalette.paper,
'--panel-border': normalizedPalette.border,
'--panel-shadow': '0 18px 48px ' + shadowColor,
'--text-main': normalizedPalette.text,
'--text-subtle': mixColors(normalizedPalette.text, normalizedPalette.bg, 0.58),
'--accent': accent,
'--accent-contrast': accentContrast,
'--accent-border': accentBorder,
'--accent-shadow': '0 4px 12px ' + buttonShadow,
'--chip-bg': chipBg,
'--chip-border': chipBorder,
'--chip-text': normalizedPalette.text,
'--code-bg': codeBg,
'--code-border': codeBorder,
'--code-text': pickReadableText(codeBg),
'--helper-bg': mixColors(normalizedPalette.paper, normalizedPalette.bg, 0.22)
}
};
}
function getSourceValue() {
var source = document.getElementById('ccf-tistory-source');
return source ? source.value : '';
}
function setSourceValue(value) {
var source = document.getElementById('ccf-tistory-source');
if (!source) return;
source.value = String(value || '');
}
function setCustomPanelVisible(visible) {
var panel = document.getElementById('ccf-theme-custom-panel');
if (!panel) return;
panel.hidden = !visible;
}
function syncCustomThemeInputs(palette) {
var normalizedPalette = normalizeThemePalette(palette, customFallbackPalette);
themeFieldDefs.forEach(function (fieldDef) {
var colorInput = document.getElementById('ccf-theme-custom-' + fieldDef.key);
var codeInput = document.getElementById('ccf-theme-custom-' + fieldDef.key + '-text');
var value = normalizedPalette[fieldDef.key] || '#000000';
if (colorInput && colorInput.value !== value) {
colorInput.value = value;
}
if (codeInput && codeInput.value !== value) {
codeInput.value = value;
}
});
}
function buildThemedTistoryHtml(mode, themeDefinition) {
var baseHtml = tistoryHtmlByMode[mode] || tistoryHtmlByMode.custom || '';
if (!baseHtml || !themeDefinition || !themeDefinition.vars) return '';
try {
var doc = new DOMParser().parseFromString(baseHtml, 'text/html');
var content = doc.querySelector('.content');
if (!content) return baseHtml;
content.setAttribute('data-ccf-theme-mode', mode);
Object.keys(themeDefinition.vars).forEach(function (key) {
content.style.setProperty(key, themeDefinition.vars[key]);
});
return content.outerHTML;
} catch (error) {
return baseHtml;
}
}
function getThemeDefinitionForMode(mode) {
if (mode === 'custom') {
var customDefinition = buildThemeDefinitionFromPalette('custom', customPalette);
themeDefinitions.custom = customDefinition;
return customDefinition;
}
return themeDefinitions && themeDefinitions[mode];
}
function commitThemeSource(mode, theme) {
setSourceValue(buildThemedTistoryHtml(mode, theme));
}
function scheduleThemeSourceUpdate() {
if (pendingSourceUpdateTimer) {
window.clearTimeout(pendingSourceUpdateTimer);
}
pendingSourceUpdateTimer = window.setTimeout(function () {
pendingSourceUpdateTimer = 0;
var theme = getThemeDefinitionForMode(currentThemeMode);
if (!theme) return;
commitThemeSource(currentThemeMode, theme);
}, 120);
}
function applyThemeMode(mode, options) {
var opts = options || {};
var theme = getThemeDefinitionForMode(mode);
if (!theme || !theme.vars) return;
var root = document.documentElement;
if (!root) return;
currentThemeMode = mode;
root.style.colorScheme = theme.colorScheme || '';
root.setAttribute('data-ccf-theme-mode', mode);
Object.keys(theme.vars).forEach(function (key) {
root.style.setProperty(key, theme.vars[key]);
});
if (opts.deferSourceUpdate) {
scheduleThemeSourceUpdate();
} else {
if (pendingSourceUpdateTimer) {
window.clearTimeout(pendingSourceUpdateTimer);
pendingSourceUpdateTimer = 0;
}
commitThemeSource(mode, theme);
}
setCustomPanelVisible(mode === 'custom');
if (mode === 'custom' && opts.syncInputs !== false) {
syncCustomThemeInputs(customPalette);
}
}
function applyCustomThemeField(key, value, options) {
var normalized = colorToHex(value);
if (!normalized) return false;
customPalette[key] = normalized;
if (!options || options.syncInputs !== false) {
syncCustomThemeInputs(customPalette);
}
if (currentThemeMode === 'custom') {
applyThemeMode('custom', {
deferSourceUpdate: !!(options && options.deferSourceUpdate),
syncInputs: false
});
}
return true;
}
function setCopyStatus(message, isError) {
var status = document.getElementById('ccf-tistory-copy-status');
if (!status) return;
status.textContent = message;
status.style.color = isError ? '#a61b1b' : 'var(--text-subtle)';
}
function fallbackCopy(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.left = '-1000px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = false;
try {
copied = document.execCommand('copy');
} catch (error) {
copied = false;
}
document.body.removeChild(textarea);
return copied;
}
async function copyTistoryHtml() {
if (pendingSourceUpdateTimer) {
window.clearTimeout(pendingSourceUpdateTimer);
pendingSourceUpdateTimer = 0;
var latestTheme = getThemeDefinitionForMode(currentThemeMode);
if (latestTheme) {
commitThemeSource(currentThemeMode, latestTheme);
}
}
var html = getSourceValue();
if (!html) {
setCopyStatus('복사할 티스토리 HTML이 비어 있습니다.', true);
return;
}
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(html);
} else if (!fallbackCopy(html)) {
throw new Error('clipboard-unavailable');
}
setCopyStatus('티스토리용 HTML이 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
} catch (error) {
if (fallbackCopy(html)) {
setCopyStatus('티스토리용 HTML이 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
return;
}
setCopyStatus('복사에 실패했습니다. 브라우저 클립보드 권한을 확인해 주세요.', true);
}
}
document.addEventListener('DOMContentLoaded', function () {
var button = document.getElementById('ccf-tistory-copy-btn');
if (button) {
button.addEventListener('click', function () {
void copyTistoryHtml();
});
}
var themeSelect = document.getElementById('ccf-theme-mode');
if (themeSelect) {
themeSelect.addEventListener('change', function () {
applyThemeMode(themeSelect.value);
});
themeSelect.value = initialThemeMode;
}
themeFieldDefs.forEach(function (fieldDef) {
var colorInput = document.getElementById('ccf-theme-custom-' + fieldDef.key);
var codeInput = document.getElementById('ccf-theme-custom-' + fieldDef.key + '-text');
if (colorInput) {
colorInput.addEventListener('input', function () {
applyCustomThemeField(fieldDef.key, colorInput.value, {
syncInputs: true,
deferSourceUpdate: true
});
});
colorInput.addEventListener('change', function () {
applyCustomThemeField(fieldDef.key, colorInput.value, {
syncInputs: true,
deferSourceUpdate: false
});
});
}
if (codeInput) {
codeInput.addEventListener('change', function () {
if (!applyCustomThemeField(fieldDef.key, codeInput.value)) {
syncCustomThemeInputs(customPalette);
}
});
codeInput.addEventListener('blur', function () {
syncCustomThemeInputs(customPalette);
});
codeInput.addEventListener('keydown', function (event) {
if (event.key !== 'Enter') return;
event.preventDefault();
if (!applyCustomThemeField(fieldDef.key, codeInput.value)) {
syncCustomThemeInputs(customPalette);
}
});
}
});
applyThemeMode(initialThemeMode);
});
})();
</script>
</head>
<body>
<textarea id="ccf-tistory-source" hidden>${escapeHtml(tistoryContentHtml)}</textarea>
${main.outerHTML}
</body>
</html>`;
}
function buildTistoryContentHtml({ roomTitle, exportedAt, entries, assets, themeDefinition = null }) {
const theme = themeDefinition || getPackageThemeDefinition();
const assetMap = buildTistoryAssetMap(assets);
const root = document.createElement("div");
root.className = "content";
root.setAttribute("data-ccf-export", "tistory-body");
root.setAttribute("data-ccf-room-title", roomTitle);
root.setAttribute("data-ccf-exported-at", exportedAt.toISOString());
root.setAttribute("data-ccf-theme-mode", theme.mode || PACKAGE_THEME_MODE_DEFAULT);
root.style.boxSizing = "border-box";
root.style.width = "100%";
root.style.maxWidth = "100%";
root.style.margin = "0";
root.style.padding = "0";
root.style.color = "var(--text-main)";
root.style.fontFamily = "\"Segoe UI\", \"Noto Sans KR\", sans-serif";
root.style.fontSize = "14px";
root.style.lineHeight = "1.6";
root.style.wordBreak = "break-word";
root.style.overflowWrap = "anywhere";
applyPackageThemeVariables(root, theme);
entries.forEach((entry, index) => {
const article = createTistoryEntryNode(entry, assetMap, theme);
if (index < entries.length - 1) {
article.style.marginBottom = "14px";
}
root.appendChild(article);
});
return root.outerHTML;
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(roomTitle)} - Tistory Body</title>
<script>
(function () {
function getContentHtml() {
var content = document.querySelector('.content');
return content ? content.outerHTML : '';
}
function setStatus(message, isError) {
var status = document.getElementById('copy-status');
if (!status) return;
status.textContent = message;
status.style.color = isError ? '#a61b1b' : '#2d241c';
}
function fallbackCopyText(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.left = '-1000px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = false;
try {
copied = document.execCommand('copy');
} catch (error) {
copied = false;
}
document.body.removeChild(textarea);
return copied;
}
async function copyContentHtml() {
var html = getContentHtml();
if (!html) {
setStatus('복사할 HTML을 찾지 못했습니다.', true);
return;
}
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(html);
} else if (!fallbackCopyText(html)) {
throw new Error('clipboard-unavailable');
}
setStatus('HTML이 클립보드에 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
} catch (error) {
if (fallbackCopyText(html)) {
setStatus('HTML이 클립보드에 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
return;
}
setStatus('복사에 실패했습니다. 브라우저의 클립보드 권한을 확인해 주세요.', true);
}
}
document.addEventListener('DOMContentLoaded', function () {
var copyButton = document.getElementById('copy-html-button');
if (copyButton) {
copyButton.addEventListener('click', function () {
void copyContentHtml();
});
}
});
document.addEventListener('keydown', function (event) {
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyC') {
event.preventDefault();
void copyContentHtml();
}
});
})();
</script>
</head>
<body style="margin:0;padding:18px;background:#ffffff;color:#2d241c;font-family:"Segoe UI","Noto Sans KR",sans-serif;">
<div style="box-sizing:border-box;max-width:960px;margin:0 auto 18px;padding:14px 16px;border:1px solid #e5dbcf;border-radius:0;background:#fff8ef;">
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;">
<div>
<div style="font-size:16px;font-weight:700;line-height:1.4;">티스토리 HTML 복사용 파일</div>
<div style="margin-top:4px;font-size:13px;line-height:1.5;color:#6b5d51;">이 페이지를 전체 선택해서 복사하지 말고, 아래 버튼이나 <strong>Alt+C</strong>로 HTML을 복사한 뒤 티스토리 HTML 모드에 붙여넣어 주세요.</div>
</div>
<button id="copy-html-button" type="button" style="cursor:pointer;border:0;border-radius:0;background:#8b5e34;color:#ffffff;padding:10px 16px;font-size:14px;font-weight:700;">HTML 복사</button>
</div>
<div id="copy-status" style="margin-top:10px;font-size:12px;line-height:1.5;color:#2d241c;">복사 대기 중</div>
</div>
${root.outerHTML}
</body>
</html>`;
}
function buildTistoryAssetMap(assets) {
return new Map(
(Array.isArray(assets) ? assets : []).map((asset) => [
asset.source,
{
...asset,
renderUrl: buildTistoryAssetRenderUrl(asset)
}
])
);
}
function buildTistoryAssetRenderUrl(asset) {
if (!asset || typeof asset !== "object") return "";
if (asset.included && asset.bytes instanceof Uint8Array) {
const mimeType = asset.mimeType || guessMimeTypeFromUrl(asset.source) || "image/png";
return `data:${mimeType};base64,${uint8ArrayToBase64(asset.bytes)}`;
}
return normalizeAssetSource(asset.source) || String(asset.source || "");
}
function resolvePackageRenderableImageUrl(value, assetMap) {
const renderable = resolveRenderableImageUrl(value);
if (!renderable) return "";
const source = normalizeAssetSource(renderable);
const mapped = source ? assetMap.get(source) : null;
return mapped?.renderUrl || renderable;
}
function createTistoryEntryNode(entry, assetMap, themeDefinition = null) {
const article = document.createElement("section");
article.style.boxSizing = "border-box";
article.style.width = "100%";
article.style.margin = "0";
article.style.padding = "16px 18px";
article.style.background = "var(--panel-bg)";
article.style.border = "1px solid var(--panel-border)";
article.style.borderRadius = "0";
article.style.boxShadow = "var(--panel-shadow)";
const avatarUrl = resolvePackageRenderableImageUrl(entry.avatarSource, assetMap);
const mainRow = document.createElement("div");
mainRow.style.display = "flex";
mainRow.style.alignItems = "flex-start";
mainRow.style.gap = "16px";
if (avatarUrl) {
const avatar = document.createElement("img");
avatar.src = avatarUrl;
avatar.alt = entry.sender ? `${entry.sender} avatar` : "avatar";
avatar.loading = "eager";
avatar.decoding = "sync";
avatar.style.display = "block";
avatar.style.width = "40px";
avatar.style.minWidth = "40px";
avatar.style.maxWidth = "40px";
avatar.style.height = "40px";
avatar.style.minHeight = "40px";
avatar.style.maxHeight = "40px";
avatar.style.flex = "0 0 40px";
avatar.style.flexShrink = "0";
avatar.style.alignSelf = "flex-start";
avatar.style.objectFit = "cover";
avatar.style.borderRadius = "0";
avatar.style.background = "var(--helper-bg)";
avatar.style.border = "1px solid var(--panel-border)";
mainRow.appendChild(avatar);
}
const content = document.createElement("div");
content.style.minWidth = "0";
content.style.flex = "1 1 auto";
if (entry.sender || entry.timestamp) {
const header = document.createElement("div");
header.style.display = "flex";
header.style.alignItems = "flex-start";
header.style.justifyContent = "space-between";
header.style.gap = "12px";
header.style.margin = "0 0 2px";
header.style.fontSize = "12px";
header.style.lineHeight = "1.4";
header.style.color = "var(--text-subtle)";
const headerMain = document.createElement("div");
headerMain.style.minWidth = "0";
headerMain.style.display = "flex";
headerMain.style.flexWrap = "wrap";
headerMain.style.alignItems = "baseline";
headerMain.style.gap = "6px";
headerMain.style.flex = "1 1 auto";
if (entry.sender) {
const sender = document.createElement("span");
sender.textContent = entry.sender;
sender.style.color = entry.baseColor || "var(--text-main)";
sender.style.fontSize = "14px";
sender.style.fontWeight = "700";
sender.style.lineHeight = "1.4";
headerMain.appendChild(sender);
}
if (entry.timestamp) {
const timestamp = document.createElement("span");
timestamp.textContent = entry.timestamp;
timestamp.style.lineHeight = "1.4";
headerMain.appendChild(timestamp);
}
header.appendChild(headerMain);
content.appendChild(header);
}
const body = buildTistoryRenderedMessageNode({
text: entry.text || entry.visibleText || "",
formatRuns: entry.formatRuns || [],
alignRuns: entry.alignRuns || [],
blockStyle: entry.blockStyle || {},
baseColor: entry.baseColor || "",
assetMap
});
body.style.margin = "0";
body.style.padding = "0";
content.appendChild(body);
mainRow.appendChild(content);
article.appendChild(mainRow);
return article;
}
function buildTistoryRenderedMessageNode({ text, formatRuns, alignRuns, blockStyle, baseColor, assetMap }) {
const wrapper = document.createElement("div");
wrapper.style.boxSizing = "border-box";
wrapper.style.width = "100%";
wrapper.style.margin = "0";
wrapper.style.padding = "0";
wrapper.style.wordBreak = "break-word";
wrapper.style.overflowWrap = "anywhere";
const normalizedText = typeof text === "string" ? text : "";
if (!normalizedText) {
wrapper.appendChild(document.createElement("br"));
return wrapper;
}
const normalizedRuns = normalizeRuns(formatRuns, normalizedText.length);
const normalizedAlignRuns = getEffectiveAlignRuns(normalizedText, alignRuns, blockStyle || {});
if (!normalizedRuns.length && !normalizedAlignRuns.length) {
wrapper.style.whiteSpace = "pre-wrap";
wrapper.textContent = normalizedText;
return wrapper;
}
const lines = getTextLines(normalizedText);
let activeCodeGroup = null;
let activeCodeGroupKey = "";
for (const line of lines) {
const lineEl = document.createElement("div");
lineEl.style.margin = "0";
lineEl.style.padding = "0";
lineEl.style.whiteSpace = "pre-wrap";
lineEl.style.wordBreak = "break-word";
lineEl.style.overflowWrap = "anywhere";
const lineAlign = getLineAlign(normalizedAlignRuns, line.index);
if (lineAlign) {
lineEl.style.textAlign = lineAlign;
}
const lineRuns = normalizedRuns
.filter((run) => run.start < line.end && run.end > line.start)
.map((run) => ({
start: clamp(run.start - line.start, 0, line.text.length),
end: clamp(run.end - line.start, 0, line.text.length),
style: { ...run.style }
}))
.filter((run) => run.end > run.start);
if (!line.text.length) {
const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns);
lineEl.appendChild(document.createElement("br"));
if (blockCodeGroupKey) {
if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
activeCodeGroup = createTistoryCodeBlockContainer();
activeCodeGroupKey = blockCodeGroupKey;
wrapper.appendChild(activeCodeGroup);
}
activeCodeGroup.appendChild(lineEl);
continue;
}
activeCodeGroup = null;
activeCodeGroupKey = "";
wrapper.appendChild(lineEl);
continue;
}
if (!lineRuns.length) {
lineEl.textContent = line.text;
activeCodeGroup = null;
activeCodeGroupKey = "";
wrapper.appendChild(lineEl);
continue;
}
const fragments = buildFragments(line.text, lineRuns);
const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns, fragments);
if (blockCodeGroupKey) {
if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
activeCodeGroup = createTistoryCodeBlockContainer();
activeCodeGroupKey = blockCodeGroupKey;
wrapper.appendChild(activeCodeGroup);
}
for (const frag of fragments) {
lineEl.appendChild(
createTistoryStyledFragmentNode(
{ ...frag, style: stripCodeModeFromStyle(frag.style) },
assetMap
)
);
}
activeCodeGroup.appendChild(lineEl);
continue;
}
activeCodeGroup = null;
activeCodeGroupKey = "";
for (const frag of fragments) {
lineEl.appendChild(createTistoryStyledFragmentNode(frag, assetMap));
}
wrapper.appendChild(lineEl);
}
return wrapper;
}
function createTistoryCodeBlockContainer() {
const block = document.createElement("div");
block.style.display = "block";
block.style.width = "100%";
block.style.margin = "6px 0";
block.style.padding = "10px 12px";
block.style.background = "var(--code-bg)";
block.style.border = "1px solid var(--code-border)";
block.style.borderRadius = "0";
block.style.boxShadow = "inset 0 1px 0 rgba(255, 255, 255, 0.03)";
block.style.boxSizing = "border-box";
block.style.color = "var(--code-text)";
block.style.fontFamily = "Consolas, \"Courier New\", monospace";
block.style.fontSize = "0.92em";
block.style.lineHeight = "1.5";
return block;
}
function createTistoryStyledFragmentNode(frag, assetMap) {
if (frag.style?.imageUrl) return createTistoryImageFragmentNode(frag, assetMap);
if (frag.style?.tooltipText) return createTistoryTooltipFragmentNode(frag, assetMap);
if (frag.style?.codeMode) return createTistoryCodeFragmentNode(frag, assetMap);
if (frag.style?.rubyText) return createTistoryRubyFragmentNode(frag, assetMap);
return createTistoryPlainTextFragmentNode(frag, assetMap);
}
function createTistoryPlainTextFragmentNode(frag, assetMap) {
const span = document.createElement("span");
span.textContent = frag.text || "";
applyTistoryInlineStyle(span, frag.style, assetMap);
return span;
}
function createTistoryTooltipFragmentNode(frag, assetMap) {
const tooltipText = normalizeTooltipText(frag.style?.tooltipText);
if (!tooltipText) {
return createTistoryStyledFragmentNode(
{ ...frag, style: cloneStyleWithoutKeys(frag.style, ["tooltipText"]) },
assetMap
);
}
const wrapper = document.createElement("span");
wrapper.title = tooltipText;
wrapper.style.borderBottom = "1px dashed currentColor";
wrapper.style.paddingBottom = "0.02em";
wrapper.style.cursor = "help";
wrapper.appendChild(
createTistoryStyledFragmentNode(
{ ...frag, style: cloneStyleWithoutKeys(frag.style, ["tooltipText"]) },
assetMap
)
);
return wrapper;
}
function createTistoryCodeFragmentNode(frag, assetMap) {
const codeMode = normalizeCodeMode(frag.style?.codeMode);
if (!codeMode) {
return createTistoryStyledFragmentNode(
{ ...frag, style: cloneStyleWithoutKeys(frag.style, ["codeMode"]) },
assetMap
);
}
const wrapper = document.createElement(codeMode === "block" ? "div" : "code");
wrapper.style.fontFamily = "Consolas, \"Courier New\", monospace";
wrapper.style.fontSize = "0.92em";
wrapper.style.lineHeight = "1.5";
wrapper.style.color = "var(--code-text)";
wrapper.style.background = "var(--code-bg)";
wrapper.style.border = "1px solid var(--code-border)";
wrapper.style.boxShadow = "inset 0 1px 0 rgba(255, 255, 255, 0.03)";
wrapper.style.boxSizing = "border-box";
if (codeMode === "block") {
wrapper.style.display = "block";
wrapper.style.width = "100%";
wrapper.style.margin = "6px 0";
wrapper.style.padding = "10px 12px";
wrapper.style.borderRadius = "0";
wrapper.style.whiteSpace = "pre-wrap";
wrapper.style.wordBreak = "break-word";
wrapper.style.overflowWrap = "anywhere";
} else {
wrapper.style.display = "inline-block";
wrapper.style.padding = "0.08em 0.45em 0.12em";
wrapper.style.borderRadius = "0";
wrapper.style.verticalAlign = "baseline";
wrapper.style.whiteSpace = "pre-wrap";
wrapper.style.overflowWrap = "anywhere";
}
wrapper.appendChild(
createTistoryStyledFragmentNode(
{ ...frag, style: cloneStyleWithoutKeys(frag.style, ["codeMode"]) },
assetMap
)
);
return wrapper;
}
function createTistoryRubyFragmentNode(frag, assetMap) {
const rubyText = normalizeRubyText(frag.style?.rubyText);
if (!rubyText) {
return createTistoryStyledFragmentNode(
{ ...frag, style: cloneStyleWithoutKeys(frag.style, ["rubyText"]) },
assetMap
);
}
const ruby = document.createElement("ruby");
ruby.style.whiteSpace = "pre-wrap";
applyTistoryInlineStyle(ruby, cloneStyleWithoutKeys(frag.style, ["rubyText"]), assetMap);
ruby.appendChild(document.createTextNode(frag.text || ""));
const rt = document.createElement("rt");
rt.textContent = rubyText;
rt.style.fontSize = "0.62em";
rt.style.lineHeight = "1";
ruby.appendChild(rt);
return ruby;
}
function createTistoryImageFragmentNode(frag, assetMap) {
const wrapper = document.createElement("span");
wrapper.style.display = "block";
wrapper.style.width = "100%";
wrapper.style.margin = "4px 0";
wrapper.style.textAlign = "center";
const imageUrl = resolveTistoryRenderableImageUrl(frag.style?.imageUrl, assetMap);
if (!imageUrl) {
const fallback = document.createElement("span");
fallback.textContent = frag.style?.imageAlt || frag.text || "image";
applyTistoryInlineStyle(fallback, cloneStyleWithoutKeys(frag.style, ["imageUrl", "imageAlt"]), assetMap);
wrapper.appendChild(fallback);
return wrapper;
}
const img = document.createElement("img");
img.src = imageUrl;
img.alt = frag.style?.imageAlt || frag.text || "image";
img.loading = "lazy";
img.decoding = "async";
img.style.display = "inline-block";
img.style.maxWidth = "100%";
img.style.height = "auto";
img.style.border = "0";
img.style.borderRadius = "0";
img.style.boxSizing = "border-box";
applyTistoryInlineStyle(img, cloneStyleWithoutKeys(frag.style, ["imageUrl", "imageAlt"]), assetMap);
wrapper.appendChild(img);
return wrapper;
}
function resolveTistoryRenderableImageUrl(value, assetMap) {
const renderable = resolveRenderableImageUrl(value);
if (!renderable) return "";
const source = normalizeAssetSource(renderable);
const mapped = source ? assetMap.get(source) : null;
return mapped?.renderUrl || renderable;
}
function applyTistoryInlineStyle(el, style, assetMap) {
if (!el || !style) return;
if (style.bold) el.style.fontWeight = "700";
if (style.italic) el.style.fontStyle = "italic";
if (style.underline || style.strike) {
const parts = [];
if (style.underline) parts.push("underline");
if (style.strike) parts.push("line-through");
el.style.textDecoration = parts.join(" ");
}
if (style.color) el.style.color = style.color;
if (style.backgroundColor) el.style.backgroundColor = style.backgroundColor;
if (style.backgroundImage) {
const rewritten = rewriteCssUrls(style.backgroundImage, assetMap);
if (rewritten) el.style.backgroundImage = rewritten;
}
if (style.fontSize) el.style.fontSize = `${style.fontSize}px`;
if (style.display) el.style.display = style.display;
if (style.padding) el.style.padding = style.padding;
if (style.margin) el.style.margin = style.margin;
if (style.border) el.style.border = style.border;
if (style.letterSpacing) el.style.letterSpacing = style.letterSpacing;
if (style.lineHeight) el.style.lineHeight = style.lineHeight;
if (style.textAlign) el.style.textAlign = style.textAlign;
if (style.textShadow) el.style.textShadow = style.textShadow;
if (style.blur) el.style.filter = `blur(${style.blur})`;
if (style.opacity != null) el.style.opacity = String(style.opacity);
}
function cloneStyleWithoutKeys(style, keys) {
if (!style || typeof style !== "object") return style ? { ...style } : {};
const nextStyle = { ...style };
for (const key of keys || []) {
delete nextStyle[key];
}
return nextStyle;
}
function extractEnvelope(fullText) {
if (typeof fullText !== "string" || !fullText) return null;
const startIndex = fullText.indexOf(INVIS_START);
const endIndex = fullText.indexOf(INVIS_END, startIndex + INVIS_START.length);
if (startIndex < 0 || endIndex < 0) return null;
const visibleText = fullText.slice(0, startIndex);
const encodedPart = fullText.slice(startIndex + INVIS_START.length, endIndex);
try {
const json = decodeInvisibleToJson(encodedPart);
const envelope = JSON.parse(json);
return { visibleText, envelope };
} catch (error) {
console.warn("[CCF LOG PACKAGE] failed to decode payload", error);
return null;
}
}
function decodeInvisibleToJson(encodedPart) {
let bits = "";
for (const char of encodedPart) {
const index = INVIS_REVERSE.get(char);
if (index == null) continue;
bits += index.toString(2).padStart(2, "0");
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.slice(i, i + 8), 2));
}
const base64 = String.fromCharCode(...bytes).replace(/\0+$/g, "");
return base64ToUtf8(base64);
}
function base64ToUtf8(base64) {
return decodeURIComponent(escape(atob(base64)));
}
function stripInvisibleEnvelope(text) {
if (typeof text !== "string" || !text) return "";
const startIndex = text.indexOf(INVIS_START);
if (startIndex < 0) return text;
const endIndex = text.indexOf(INVIS_END, startIndex + INVIS_START.length);
if (endIndex < 0) return text;
return text.slice(0, startIndex) + text.slice(endIndex + INVIS_END.length);
}
function normalizeAssetSource(value) {
if (typeof value !== "string") return "";
let trimmed = value.trim();
if (!trimmed) return "";
if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) {
return trimmed.replace(/\s+/g, "");
}
if (/^\/\//.test(trimmed)) {
trimmed = `https:${trimmed}`;
}
try {
const parsed = new URL(trimmed, location.href);
if (!/^(https?|blob):$/i.test(parsed.protocol)) return "";
return parsed.toString();
} catch (error) {
return "";
}
}
function extractCssUrls(value) {
if (typeof value !== "string" || !value) return [];
const out = [];
const re = /url\((.*?)\)/gi;
let match = re.exec(value);
while (match) {
const raw = String(match[1] || "").trim().replace(/^['"]|['"]$/g, "");
const normalized = normalizeAssetSource(raw);
if (normalized) out.push(normalized);
match = re.exec(value);
}
return out;
}
function rewriteCssUrls(value, assetMap) {
if (typeof value !== "string" || !value) return "";
return value.replace(/url\((.*?)\)/gi, (match, raw) => {
const source = normalizeAssetSource(String(raw || "").trim().replace(/^['"]|['"]$/g, ""));
const mapped = source ? assetMap.get(source) : null;
if (!mapped?.renderUrl) return match;
return `url("${escapeCssUrl(mapped.renderUrl)}")`;
});
}
function escapeCssUrl(value) {
return String(value || "").replace(/["\\\r\n]/g, "\\$&");
}
function parseDataUrl(value) {
const match = String(value || "").match(/^data:([^;,]+);base64,(.+)$/i);
if (!match) return null;
try {
const mimeType = match[1].toLowerCase();
const binary = atob(match[2]);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return { mimeType, bytes };
} catch (error) {
return null;
}
}
function uint8ArrayToBase64(bytes) {
if (!(bytes instanceof Uint8Array) || !bytes.length) return "";
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
function guessFileExtension(mimeType, source) {
const byMime = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
"image/svg+xml": "svg",
"image/avif": "avif"
};
if (byMime[mimeType]) return byMime[mimeType];
const cleaned = String(source || "").split(/[?#]/, 1)[0];
const extMatch = cleaned.match(/\.([a-z0-9]{2,6})$/i);
if (extMatch) {
return extMatch[1].toLowerCase();
}
return "bin";
}
function guessMimeTypeFromUrl(source) {
const ext = guessFileExtension("", source);
const byExt = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
svg: "image/svg+xml",
avif: "image/avif"
};
return byExt[ext] || "";
}
function buildPackageFileName(roomTitle, roomAddress, exportedAt) {
const safeTitle = sanitizeFilePart(roomTitle || "ccfolia-room");
const safeAddress = sanitizeFilePart(roomAddress || "room");
const stamp = formatFileDate(exportedAt);
return `${safeTitle}(${safeAddress})-${stamp}.zip`;
}
function sanitizeFilePart(value) {
const normalized = normalizeSpace(String(value || "")).replace(/[<>:"/\\|?*\u0000-\u001F]/g, "");
return normalized.slice(0, 80) || "ccfolia-room";
}
function getRoomTitle() {
const subtitle = [...document.querySelectorAll('h6.MuiTypography-subtitle2, h6[class*="MuiTypography-subtitle2"]')]
.find((element) => element instanceof HTMLElement && isVisible(element));
if (subtitle instanceof HTMLElement) {
const ownText = getOwnTextContent(subtitle);
if (isUsableRoomTitle(ownText)) {
return ownText;
}
}
const heading = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')]
.find((element) => element instanceof HTMLElement && isVisible(element) && isUsableRoomTitle(getOwnTextContent(element)));
if (heading instanceof HTMLElement) {
return getOwnTextContent(heading);
}
const cleanedTitle = normalizeSpace(String(document.title || "").replace(/\s*[|-]\s*CCFOLIA.*$/i, ""));
if (isUsableRoomTitle(cleanedTitle)) {
return cleanedTitle;
}
const slug = location.pathname.split("/").filter(Boolean).pop();
return slug || "CCFOLIA Room";
}
function getOwnTextContent(element) {
if (!(element instanceof HTMLElement)) return "";
const directText = [...element.childNodes]
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent || "")
.join(" ");
const normalizedDirect = normalizeSpace(directText);
if (normalizedDirect) return normalizedDirect;
return normalizeSpace(element.textContent || "");
}
function isUsableRoomTitle(value) {
const text = normalizeSpace(value);
if (!text) return false;
if (/^ccfolia\b/i.test(text)) return false;
if (/trpgオンラインセッションツール/i.test(text)) return false;
return true;
}
function getRoomAddressLabel() {
const parts = location.pathname.split("/").filter(Boolean);
const lastPart = parts[parts.length - 1] || "";
if (lastPart) return lastPart;
const fallback = `${location.hostname}${location.pathname}`.replace(/[/:]+/g, "-");
return fallback || "room";
}
function downloadBlob(fileName, blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function makeZipEntry(name, data, date) {
return {
name,
data,
date: date instanceof Date ? date : new Date()
};
}
function buildStoredZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of entries) {
const nameBytes = encodeUtf8(entry.name);
const dataBytes = entry.data instanceof Uint8Array ? entry.data : encodeUtf8(String(entry.data || ""));
const crc = crc32(dataBytes);
const dos = getDosDateTime(entry.date);
const localHeader = new Uint8Array(30);
const localView = new DataView(localHeader.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint16(6, 0x0800, true);
localView.setUint16(8, 0, true);
localView.setUint16(10, dos.time, true);
localView.setUint16(12, dos.date, true);
localView.setUint32(14, crc, true);
localView.setUint32(18, dataBytes.length, true);
localView.setUint32(22, dataBytes.length, true);
localView.setUint16(26, nameBytes.length, true);
localView.setUint16(28, 0, true);
locals.push(localHeader, nameBytes, dataBytes);
const centralHeader = new Uint8Array(46);
const centralView = new DataView(centralHeader.buffer);
centralView.setUint32(0, 0x02014b50, true);
centralView.setUint16(4, 20, true);
centralView.setUint16(6, 20, true);
centralView.setUint16(8, 0x0800, true);
centralView.setUint16(10, 0, true);
centralView.setUint16(12, dos.time, true);
centralView.setUint16(14, dos.date, true);
centralView.setUint32(16, crc, true);
centralView.setUint32(20, dataBytes.length, true);
centralView.setUint32(24, dataBytes.length, true);
centralView.setUint16(28, nameBytes.length, true);
centralView.setUint16(30, 0, true);
centralView.setUint16(32, 0, true);
centralView.setUint16(34, 0, true);
centralView.setUint16(36, 0, true);
centralView.setUint32(38, 0, true);
centralView.setUint32(42, offset, true);
centrals.push(centralHeader, nameBytes);
offset += localHeader.length + nameBytes.length + dataBytes.length;
}
const centralOffset = offset;
const centralSize = sumLengths(centrals);
const centralRecordCount = entries.length;
const endHeader = new Uint8Array(22);
const endView = new DataView(endHeader.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(4, 0, true);
endView.setUint16(6, 0, true);
endView.setUint16(8, centralRecordCount, true);
endView.setUint16(10, centralRecordCount, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, centralOffset, true);
endView.setUint16(20, 0, true);
return concatUint8Arrays([...locals, ...centrals, endHeader]);
}
function getDosDateTime(value) {
const date = value instanceof Date ? value : new Date();
const year = Math.max(1980, date.getFullYear());
return {
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2)
};
}
function crc32(bytes) {
const table = getCrc32Table();
let crc = 0 ^ -1;
for (let i = 0; i < bytes.length; i += 1) {
crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xFF];
}
return (crc ^ -1) >>> 0;
}
let CRC32_TABLE = null;
function getCrc32Table() {
if (CRC32_TABLE) return CRC32_TABLE;
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let current = i;
for (let j = 0; j < 8; j += 1) {
current = (current & 1) ? (0xEDB88320 ^ (current >>> 1)) : (current >>> 1);
}
table[i] = current >>> 0;
}
CRC32_TABLE = table;
return table;
}
function concatUint8Arrays(parts) {
const total = sumLengths(parts);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function sumLengths(parts) {
return parts.reduce((sum, part) => sum + part.length, 0);
}
function encodeUtf8(value) {
return new TextEncoder().encode(String(value || ""));
}
function cloneJson(value) {
try {
return JSON.parse(JSON.stringify(value));
} catch (error) {
return Array.isArray(value) ? [] : {};
}
}
function formatDisplayDate(date) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
function formatFileDate(date) {
const pad = (value) => String(value).padStart(2, "0");
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("") + "-" + [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("");
}
function normalizeText(value) {
return typeof value === "string" ? value.replace(/\r\n?/g, "\n") : "";
}
function normalizeSpace(value) {
return normalizeText(value).replace(/\s+/g, " ").trim();
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function isVisible(element) {
if (!(element instanceof HTMLElement)) return false;
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
})();