/* * ChatGPT Conversation Toolkit - Toolbar and drag behavior */ const TOOLKIT_REPO_URL = "https://github.com/bujue3709/chatgpt-Long-conversation-optimization"; const TOOLKIT_FEEDBACK_URL = `${TOOLKIT_REPO_URL}/issues/new`; const getSnappedFloatingButtonPlacement = (left, top, width, height) => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const margin = 16; const centerX = left + width / 2; const edge = centerX <= viewportWidth / 2 ? "left" : "right"; const nextTop = clampFloatingButtonPosition(left, top, width, height).top; return { edge, top: Math.min(nextTop, Math.max(margin, viewportHeight - height - margin)), }; }; const applySnappedFloatingButtonPlacement = (button, placement, savePosition = true) => { if (!(button instanceof HTMLElement) || !placement) { return; } button.style.transform = ""; if (placement.edge === "left") { button.style.left = "16px"; button.style.right = "auto"; } else { button.style.left = "auto"; button.style.right = "16px"; } button.style.top = `${Math.round(placement.top)}px`; button.style.bottom = "auto"; if (savePosition) { saveMinimizedPosition({ edge: placement.edge, top: Math.round(placement.top), }); } }; const snapToEdge = (button, savePosition = true) => { const rect = button.getBoundingClientRect(); const placement = getSnappedFloatingButtonPlacement(rect.left, rect.top, rect.width, rect.height); applySnappedFloatingButtonPlacement(button, placement, savePosition); }; const ensureButtonVisible = (button) => { const rect = button.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const margin = 16; let needsAdjustment = false; // 检查是否超出可视区域 if (rect.left < 0 || rect.right > viewportWidth || rect.top < 0 || rect.bottom > viewportHeight) { needsAdjustment = true; } if (needsAdjustment) { snapToEdge(button, true); } }; const getToolbarLanguageOptionsMarkup = () => { const selectedPreference = getLanguagePreference(); const options = [ { value: TOOLKIT_LANGUAGE_AUTO, label: getLanguageMenuLabel(TOOLKIT_LANGUAGE_AUTO), }, { value: "en", label: t("language.english"), }, { value: "zh-CN", label: t("language.chinese"), }, ]; return options .map( (option) => ``, ) .join(""); }; const refreshMinimizedButtonLocalization = () => { const button = document.getElementById(MINIMIZED_ID); if (!(button instanceof HTMLButtonElement)) { return; } button.setAttribute("aria-label", t("toolbar.expandAria")); }; const buildToolbarTickerMarkup = (text) => ``; const refreshToolbarLocalization = () => { const toolbar = document.getElementById(TOOLKIT_ID); if (!(toolbar instanceof HTMLElement)) { refreshMinimizedButtonLocalization(); return; } const title = toolbar.querySelector(".chatgpt-toolkit-title"); if (title instanceof HTMLElement) { title.textContent = t("toolbar.title"); } const subtitle = toolbar.querySelector(".chatgpt-toolkit-subtitle"); if (subtitle instanceof HTMLElement) { subtitle.textContent = t("toolbar.subtitle"); } const minimizeButton = toolbar.querySelector('[data-action="minimize"]'); if (minimizeButton instanceof HTMLButtonElement) { minimizeButton.textContent = t("toolbar.minimize"); minimizeButton.setAttribute("aria-label", t("toolbar.minimizeAria")); } const languageLabel = toolbar.querySelector(".chatgpt-toolkit-language-label"); if (languageLabel instanceof HTMLElement) { languageLabel.textContent = t("language.label"); } const languageSelect = toolbar.querySelector("#chatgpt-toolkit-language-select"); if (languageSelect instanceof HTMLSelectElement) { languageSelect.innerHTML = getToolbarLanguageOptionsMarkup(); languageSelect.value = getLanguagePreference(); languageSelect.setAttribute("aria-label", t("language.label")); } const exportButton = toolbar.querySelector('[data-action="export"]'); if (exportButton instanceof HTMLButtonElement) { exportButton.textContent = t("toolbar.export"); } const promptButton = toolbar.querySelector('[data-action="prompt-library"]'); if (promptButton instanceof HTMLButtonElement) { promptButton.textContent = t("toolbar.promptLibrary"); } const settingsButton = toolbar.querySelector('[data-action="settings"]'); if (settingsButton instanceof HTMLButtonElement) { settingsButton.textContent = t("toolbar.settings"); } const searchInput = toolbar.querySelector("#chatgpt-toolkit-search-input"); if (searchInput instanceof HTMLInputElement) { searchInput.placeholder = t("toolbar.searchPlaceholder"); } const searchButton = toolbar.querySelector('[data-action="search"]'); if (searchButton instanceof HTMLButtonElement) { searchButton.textContent = t("toolbar.search"); searchButton.title = t("toolbar.searchTitle"); } const prevButton = toolbar.querySelector('[data-action="search-prev"]'); if (prevButton instanceof HTMLButtonElement) { prevButton.textContent = t("toolbar.searchPrev"); prevButton.title = t("toolbar.searchPrevTitle"); } const nextButton = toolbar.querySelector('[data-action="search-next"]'); if (nextButton instanceof HTMLButtonElement) { nextButton.textContent = t("toolbar.searchNext"); nextButton.title = t("toolbar.searchNextTitle"); } const starButton = toolbar.querySelector('[data-action="open-star-project"]'); if (starButton instanceof HTMLButtonElement) { starButton.title = t("toolbar.starProjectAria"); starButton.setAttribute("aria-label", t("toolbar.starProjectAria")); const viewport = starButton.querySelector(".chatgpt-toolkit-cta-viewport"); if (viewport instanceof HTMLElement) { viewport.innerHTML = buildToolbarTickerMarkup(t("toolbar.starProject")); } } const feedbackButton = toolbar.querySelector('[data-action="open-feedback"]'); if (feedbackButton instanceof HTMLButtonElement) { feedbackButton.title = t("toolbar.feedbackAria"); feedbackButton.setAttribute("aria-label", t("toolbar.feedbackAria")); const viewport = feedbackButton.querySelector(".chatgpt-toolkit-cta-viewport"); if (viewport instanceof HTMLElement) { viewport.innerHTML = buildToolbarTickerMarkup(t("toolbar.feedback")); } } refreshStatusLocalization(); refreshMinimizedButtonLocalization(); updateSearchUI(); updateTimelineToggleButton(); }; const buildToolbar = () => { const container = document.createElement("section"); container.id = TOOLKIT_ID; container.innerHTML = `
${t("toolbar.title")}
${t("toolbar.subtitle")}

${t("toolbar.ready")}

`; container.addEventListener("click", (event) => { const target = event.target; const actionTarget = target instanceof Element ? target.closest("[data-action]") : target instanceof Node && target.parentElement ? target.parentElement.closest("[data-action]") : null; if (!(actionTarget instanceof HTMLElement)) { return; } const action = actionTarget.dataset.action; if (!action) { return; } const actionHandlers = { minimize: () => minimizeToolbar(), export: () => void openExportSelectionModal(), "prompt-library": () => void openPromptModal(), "timeline-toggle": () => toggleTimelineVisibility(), settings: () => openSettingsModal(), "open-star-project": () => openToolkitLink(TOOLKIT_REPO_URL), "open-feedback": () => openToolkitLink(TOOLKIT_FEEDBACK_URL), search: () => { const input = document.getElementById('chatgpt-toolkit-search-input'); if (input) performSearch(input.value); }, "search-prev": () => navigateToPrevMatch(), "search-next": () => navigateToNextMatch(), }; const handler = actionHandlers[action]; if (handler) handler(); }); // 监听搜索输入框的回车事件 container.addEventListener("keydown", (event) => { const target = event.target; if (target.id === 'chatgpt-toolkit-search-input' && event.key === 'Enter') { performSearch(target.value); } }); container.addEventListener("change", (event) => { const target = event.target; if (!(target instanceof HTMLSelectElement) || target.id !== "chatgpt-toolkit-language-select") { return; } setLanguagePreference(target.value, { persist: true, refresh: true }); }); return container; }; const buildMinimizedButton = () => { const button = document.createElement("button"); button.id = MINIMIZED_ID; button.type = "button"; button.className = "chatgpt-toolkit-minimized"; button.setAttribute("aria-label", t("toolbar.expandAria")); button.innerHTML = ``; return button; }; const applyMinimizedPosition = (button) => { const position = loadMinimizedPosition(); if (!position) { // 默认位置:右边缘 snapToEdge(button, false); return; } const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const buttonHeight = button.offsetHeight || 48; const margin = 16; // 新格式:edge + top if (position.edge && typeof position.top === "number") { let top = position.top; // 确保 top 在可视区域内 if (top < margin) { top = margin; } else if (top + buttonHeight > viewportHeight - margin) { top = viewportHeight - buttonHeight - margin; } if (position.edge === 'left') { button.style.left = `${margin}px`; button.style.right = 'auto'; } else { button.style.left = 'auto'; button.style.right = `${margin}px`; } button.style.top = `${top}px`; button.style.bottom = 'auto'; return; } // 兼容旧格式:left + top(迁移到新格式) if (typeof position.left === "number" && typeof position.top === "number") { let top = position.top; // 确保 top 在可视区域内 if (top < margin) { top = margin; } else if (top + buttonHeight > viewportHeight - margin) { top = viewportHeight - buttonHeight - margin; } // 判断应该贴哪个边 const centerX = position.left + 24; // 按钮宽度的一半 const edge = centerX <= viewportWidth / 2 ? 'left' : 'right'; if (edge === 'left') { button.style.left = `${margin}px`; button.style.right = 'auto'; } else { button.style.left = 'auto'; button.style.right = `${margin}px`; } button.style.top = `${top}px`; button.style.bottom = 'auto'; // 保存为新格式 saveMinimizedPosition({ edge, top }); } }; const ensureMinimizedButton = () => { const existingButton = document.getElementById(MINIMIZED_ID); if (existingButton) { return existingButton; } if (!document.body) { return null; } const button = buildMinimizedButton(); document.body.appendChild(button); applyMinimizedPosition(button); enableDrag(button); syncToolkitTheme(); return button; }; const applyToolbarVisibility = () => { const toolbar = document.getElementById(TOOLKIT_ID); const minimized = ensureMinimizedButton(); if (!(toolbar instanceof HTMLElement) || !(minimized instanceof HTMLElement)) { return; } toolbar.classList.toggle("is-hidden", Boolean(state.isMinimized)); minimized.classList.toggle("is-visible", Boolean(state.isMinimized)); }; const minimizeToolbar = () => { state.isMinimized = true; saveToolbarMinimizedState(true); applyToolbarVisibility(); }; const expandToolbar = () => { state.isMinimized = false; saveToolbarMinimizedState(false); applyToolbarVisibility(); }; const applyFloatingButtonPosition = (button, left, top) => { button.style.left = `${Math.round(left)}px`; button.style.top = `${Math.round(top)}px`; button.style.right = "auto"; button.style.bottom = "auto"; }; const clampFloatingButtonPosition = (left, top, width, height) => { const margin = 8; const maxLeft = Math.max(margin, window.innerWidth - width - margin); const maxTop = Math.max(margin, window.innerHeight - height - margin); return { left: Math.min(Math.max(left, margin), maxLeft), top: Math.min(Math.max(top, margin), maxTop), }; }; const enableDrag = (button) => { const DRAG_THRESHOLD = 5; // 拖拽阈值:超过5px才判定为拖拽 let isDragging = false; let moved = false; let suppressClick = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let pendingLeft = 0; let pendingTop = 0; let buttonWidth = 48; let buttonHeight = 48; const baseTransform = ""; const dragController = createRafDragController(({ translateX, translateY }) => { applyDragTransform(button, translateX, translateY, baseTransform); }); const onPointerMove = (event) => { if (!isDragging) { return; } const deltaX = event.clientX - startX; const deltaY = event.clientY - startY; // 只有超过阈值才判定为拖拽 if (!moved) { const distanceSquared = deltaX * deltaX + deltaY * deltaY; if (distanceSquared < DRAG_THRESHOLD * DRAG_THRESHOLD) { return; // 未超过阈值,不算拖拽 } moved = true; // 超过阈值,标记为拖拽 suppressClick = true; minimizedButtonState.dragging = true; button.classList.add("is-dragging"); button.style.willChange = "transform"; button.style.pointerEvents = "none"; document.documentElement.style.userSelect = "none"; } const nextPosition = clampFloatingButtonPosition( startLeft + deltaX, startTop + deltaY, buttonWidth, buttonHeight ); pendingLeft = nextPosition.left; pendingTop = nextPosition.top; dragController.schedule({ translateX: nextPosition.left - startLeft, translateY: nextPosition.top - startTop, }); }; const onPointerUp = () => { if (!isDragging) { return; } isDragging = false; minimizedButtonState.pointerDown = false; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); document.removeEventListener("pointercancel", onPointerUp); dragController.cancel(); minimizedButtonState.dragging = false; button.classList.remove("is-pointer-down"); button.classList.remove("is-dragging"); button.style.willChange = ""; button.style.pointerEvents = ""; document.documentElement.style.userSelect = ""; // 只有实际拖动了才贴合边缘 if (moved) { applySnappedFloatingButtonPlacement( button, getSnappedFloatingButtonPlacement(pendingLeft, pendingTop, buttonWidth, buttonHeight), true ); } else { resetDragTransform(button, baseTransform); } setTimeout(() => { moved = false; suppressClick = false; }, 0); }; button.style.touchAction = "none"; button.addEventListener("pointerdown", (event) => { if (event.button !== 0) { return; } event.preventDefault(); isDragging = true; minimizedButtonState.pointerDown = true; moved = false; button.classList.add("is-pointer-down"); const rect = button.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; pendingLeft = rect.left; pendingTop = rect.top; buttonWidth = rect.width || button.offsetWidth || 48; buttonHeight = rect.height || button.offsetHeight || 48; startX = event.clientX; startY = event.clientY; resetDragTransform(button, baseTransform); document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); document.addEventListener("pointercancel", onPointerUp); }); button.addEventListener("click", () => { if (moved || suppressClick) { suppressClick = false; return; } expandToolbar(); }); }; const attachToolbar = () => { if (document.getElementById(TOOLKIT_ID)) { return; } if (!document.body) { return; } observeThemeOnBodyIfNeeded(); const toolbar = buildToolbar(); document.body.appendChild(toolbar); updateStatusByKey("toolbar.ready", "info"); updateTimelineToggleButton(); ensureMinimizedButton(); applyToolbarVisibility(); refreshToolbarLocalization(); syncToolkitTheme(); }; const openToolkitLink = (url) => { if (!url) { return; } window.open(url, "_blank", "noopener,noreferrer"); }; const SETTINGS_MODAL_ID = "chatgpt-toolkit-settings-modal"; const SETTINGS_INPUT_IDS = Object.freeze({ exportFormat: "toolkit-setting-exportFormat", exportRole: "toolkit-setting-exportRole", }); const getSettingsModal = () => document.getElementById(SETTINGS_MODAL_ID); const closeSettingsModal = () => { const modal = getSettingsModal(); if (modal instanceof HTMLElement) { modal.classList.remove("is-visible"); } }; const getSettingsFallbackConfig = () => ({ exportFormat: TOOLKIT_EXPORT_FORMAT || TOOLKIT_EXPORT_FORMAT_JSON, exportRole: TOOLKIT_EXPORT_ROLE || TOOLKIT_EXPORT_ROLE_ALL, }); const getSettingsConfig = (config) => { const currentConfig = typeof getToolkitConfig === "function" ? getToolkitConfig() : getSettingsFallbackConfig(); if (config && typeof normalizeToolkitConfig === "function") { return normalizeToolkitConfig({ ...currentConfig, ...config }); } return currentConfig; }; const readSettingsInputValue = (id) => { const input = document.getElementById(id); if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { return input.value; } return undefined; }; const readSettingsDraft = () => { const draft = { exportFormat: readSettingsInputValue(SETTINGS_INPUT_IDS.exportFormat), exportRole: readSettingsInputValue(SETTINGS_INPUT_IDS.exportRole), }; return Object.values(draft).some((value) => value !== undefined) ? draft : null; }; const renderSettingsModal = (modal, draftConfig = null) => { if (!(modal instanceof HTMLElement)) { return; } const config = getSettingsConfig(draftConfig); modal.innerHTML = `
`; const saveBtn = modal.querySelector(".chatgpt-toolkit-settings-save"); if (saveBtn instanceof HTMLButtonElement) { saveBtn.addEventListener("click", () => { const draft = readSettingsDraft(); if (typeof saveToolkitConfig === "function") { saveToolkitConfig(draft || {}); } else { getSettingsConfig(draft); } if (typeof updateStatusByKey === "function") { updateStatusByKey("settings.saved", "success"); } closeSettingsModal(); }); } }; const refreshSettingsLocalization = () => { const modal = getSettingsModal(); if (!(modal instanceof HTMLElement)) { return; } const wasVisible = modal.classList.contains("is-visible"); renderSettingsModal(modal, readSettingsDraft()); modal.classList.toggle("is-visible", wasVisible); if (typeof syncToolkitTheme === "function") { syncToolkitTheme(); } }; const openSettingsModal = () => { let modal = getSettingsModal(); if (!modal) { modal = document.createElement("section"); modal.id = SETTINGS_MODAL_ID; modal.className = "chatgpt-toolkit-prompt-modal"; document.body.appendChild(modal); modal.addEventListener("click", (e) => { const target = e.target; const actionTarget = target instanceof Element ? target.closest("[data-settings-action]") : target instanceof Node && target.parentElement ? target.parentElement.closest("[data-settings-action]") : null; if (actionTarget) { if (actionTarget.dataset.settingsAction === "close") { closeSettingsModal(); } } }); if (typeof syncToolkitTheme === "function") { syncToolkitTheme(); } } renderSettingsModal(modal); setTimeout(() => modal.classList.add("is-visible"), 10); }; // 标志位:避免重复添加 resize 监听器