From 4bb56f8fc2adab3c62c6ef25a8022fc5cb0b76a4 Mon Sep 17 00:00:00 2001 From: Pionxzh Date: Mon, 13 Nov 2023 03:27:02 +0800 Subject: [PATCH] fix: fix screenshot and conversation choice closes #184, #186 --- src/exporter/image.ts | 295 +++++++++++++++++++++++++++++++----------- src/page.ts | 8 +- 2 files changed, 229 insertions(+), 74 deletions(-) diff --git a/src/exporter/image.ts b/src/exporter/image.ts index 82fad07..3e98eb0 100644 --- a/src/exporter/image.ts +++ b/src/exporter/image.ts @@ -1,6 +1,6 @@ import html2canvas from 'html2canvas' import i18n from '../i18n' -import { checkIfConversationStarted, conversationChoiceSelector, getChatIdFromUrl } from '../page' +import { checkIfConversationStarted, conversationChoiceSelector, getChatIdFromUrl, isGizmoMode } from '../page' import { downloadUrl, getFileNameWithFormat } from '../utils/download' import { Effect } from '../utils/effect' import { sleep } from '../utils/utils' @@ -16,79 +16,228 @@ export async function exportToPng(fileNameFormat: string) { return false } - const thread = document.querySelector('main .group')?.parentElement as HTMLElement - if (!thread || thread.children.length === 0) return false - const effect = new Effect() - // hide model bar - const modelBar = thread.firstElementChild - if (modelBar?.textContent?.startsWith('Model:')) { + let thread: HTMLElement | null = null + + const isGizmo = isGizmoMode() + if (isGizmo) { + thread = document.querySelector('main [class^=\'react-scroll-to-bottom\'] > div > div') + if (!thread || thread.children.length === 0 || thread.scrollHeight < 50) { + alert(i18n.t('Failed to export to PNG. Failed to find the element node.')) + return false + } + + // gizmo style caused too much weird stuff... transparent background, weird spacing, etc. effect.add(() => { - modelBar.classList.add('hidden') - return () => modelBar.classList.remove('hidden') + document.documentElement.classList.remove('gizmo') + return () => document.documentElement.classList.add('gizmo') + }) + + effect.add(() => { + document.documentElement.style.setProperty('font-size', '12px') + return () => document.documentElement.style.removeProperty('font-size') + }) + + // pre-calculated rem to px for tailwindcss + // effect.add(() => { + // const stylesheet = new CSSStyleSheet() + // stylesheet.replaceSync(` + // .text-xs { + // font-size: 12px; // 0.75rem + // line-height: 16px; // 1rem + // } + + // .text-sm { + // font-size: 14px; // 0.875rem + // line-height: 20px; // 1.25rem + // } + + // .text-base { + // font-size: 16px; // 1rem + // line-height: 24px; // 1.5rem + // } + + // .text-xl { + // font-size: 24px; // 1.5rem + // line-height: 32px; // 2rem + // } + + // .px-4 { + // padding-left: 16px; // 1rem + // padding-right: 16px; // 1rem + // } + + // .py-2 { + // padding-top: 8px; // 0.5rem + // padding-bottom: 8px; // 0.5rem + // } + + // .font-semibold { + // line-height: 20px; + // } + // `) + // const oldAdoptedStyleSheets = document.adoptedStyleSheets + // document.adoptedStyleSheets = [...oldAdoptedStyleSheets, stylesheet] + // return () => { + // stylesheet.replaceSync('') + // document.adoptedStyleSheets = oldAdoptedStyleSheets + // } + // }) + + effect.add(() => { + const style = document.createElement('style') + style.textContent = ` + pre { + margin-top: 8px !important; + } + + pre > div > div > span { + margin-top: -12px; + padding-bottom: 2px; + } + ` + thread!.appendChild(style) + return () => style.remove() + }) + + const conversationNodes = document.querySelectorAll('[data-testid^="conversation-turn-"]') + conversationNodes.forEach((node) => { + effect.add(() => { + node.style.height = `${node.clientHeight}px` + return () => node.style.removeProperty('height') + }) + }) + + // hide top header + const topHeader = thread.querySelector('.sticky.top-0') + if (topHeader) { + effect.add(() => { + topHeader.classList.add('hidden') + return () => topHeader.classList.remove('hidden') + }) + } + + // hide buttons + const buttonWrappers = document.querySelectorAll('main .flex.justify-between') + buttonWrappers.forEach((wrapper) => { + if (!wrapper.querySelector('button')) return + // ignore codeblock + if (wrapper.closest('pre')) return + + effect.add(() => { + wrapper.style.display = 'none' + return () => wrapper.style.display = '' + }) + }) + + // hide code block copy button + const copyButtons = thread.querySelectorAll('pre button') + copyButtons.forEach((button) => { + effect.add(() => { + button.classList.add('hidden') + return () => button.classList.remove('hidden') + }) + }) + + // hide back to top button + const backToTop = thread.querySelectorAll('button.absolute') + backToTop.forEach((button) => { + effect.add(() => { + button.classList.add('hidden') + return () => button.classList.remove('hidden') + }) + }) + + // hide weird cover on avatar + const shadowStrokes = thread.querySelectorAll('.gizmo-shadow-stroke') + shadowStrokes.forEach((stroke) => { + effect.add(() => { + stroke.classList.remove('gizmo-shadow-stroke') + return () => stroke.classList.add('gizmo-shadow-stroke') + }) + }) + } + else { + thread = document.querySelector('main .group')?.parentElement ?? null + if (!thread || thread.children.length === 0 || thread.scrollHeight < 50) { + alert(i18n.t('Failed to export to PNG. Failed to find the element node.')) + return false + } + + const threadEl = thread as HTMLElement + + // hide model bar + const modelBar = threadEl.firstElementChild + if (modelBar?.textContent?.startsWith('Model:')) { + effect.add(() => { + modelBar.classList.add('hidden') + return () => modelBar.classList.remove('hidden') + }) + } + + // hide bottom bar + effect.add(() => { + const bottomBar = threadEl.children[threadEl.children.length - 1] + bottomBar.classList.add('hidden') + return () => bottomBar.classList.remove('hidden') + }) + + // hide buttons + const buttonWrappers = document.querySelectorAll('main .flex.justify-between') + buttonWrappers.forEach((wrapper) => { + if (!wrapper.querySelector('button')) return + // ignore codeblock + if (wrapper.closest('pre')) return + + effect.add(() => { + wrapper.style.display = 'none' + return () => wrapper.style.display = '' + }) + }) + + // hide conversation choices. eg. <1 / 6> + const conversationChoices = document.querySelectorAll(conversationChoiceSelector) + conversationChoices.forEach((choice) => { + effect.add(() => { + const parent = choice.parentElement + if (!parent) return + parent.classList.add('hidden') + return () => parent.classList.remove('hidden') + }) + }) + + // disabled the avatar srcset + // fix https://github.com/pionxzh/chatgpt-exporter/issues/53 + // seems related to https://github.com/niklasvh/html2canvas/issues/2218 + const avatarEls = Array.from(document.querySelectorAll('img[alt]:not([aria-hidden])')) + avatarEls.forEach((el) => { + const srcset = el.getAttribute('srcset') + if (srcset) { + effect.add(() => { + el.setAttribute('data-srcset', srcset) + el.removeAttribute('srcset') + return () => { + el.setAttribute('srcset', srcset) + el.removeAttribute('data-srcset') + } + }) + } + }) + + // add `break-words` to all message elements + // html2canvas cannot handle the spacing correctly on Firefox with MacOS + // fix https://github.com/pionxzh/chatgpt-exporter/issues/78 + const messageEls = Array.from(threadEl.querySelectorAll('.group .whitespace-pre-wrap')) + messageEls.forEach((el) => { + effect.add(() => { + el.classList.add('break-words') + return () => el.classList.remove('break-words') + }) }) } - // hide bottom bar - effect.add(() => { - const bottomBar = thread.children[thread.children.length - 1] - bottomBar.classList.add('hidden') - return () => bottomBar.classList.remove('hidden') - }) - - // hide buttons - const buttonWrappers = document.querySelectorAll('main .flex.justify-between') - buttonWrappers.forEach((wrapper) => { - if (!wrapper.querySelector('button')) return - // ignore codeblock - if (wrapper.closest('pre')) return - - effect.add(() => { - wrapper.style.display = 'none' - return () => wrapper.style.display = '' - }) - }) - - // hide conversation choices. eg. <1 / 6> - const conversationChoices = document.querySelectorAll(conversationChoiceSelector) - conversationChoices.forEach((choice) => { - effect.add(() => { - const parent = choice.parentElement - if (!parent) return - parent.classList.add('hidden') - return () => parent.classList.remove('hidden') - }) - }) - - // disabled the avatar srcset - // fix https://github.com/pionxzh/chatgpt-exporter/issues/53 - // seems related to https://github.com/niklasvh/html2canvas/issues/2218 - const avatarEls = Array.from(document.querySelectorAll('img[alt]:not([aria-hidden])')) - avatarEls.forEach((el) => { - const srcset = el.getAttribute('srcset') - if (srcset) { - effect.add(() => { - el.setAttribute('data-srcset', srcset) - el.removeAttribute('srcset') - return () => { - el.setAttribute('srcset', srcset) - el.removeAttribute('data-srcset') - } - }) - } - }) - - // add `break-words` to all message elements - // html2canvas cannot handle the spacing correctly on Firefox with MacOS - // fix https://github.com/pionxzh/chatgpt-exporter/issues/78 - const messageEls = Array.from(thread.querySelectorAll('.group .whitespace-pre-wrap')) - messageEls.forEach((el) => { - effect.add(() => { - el.classList.add('break-words') - return () => el.classList.remove('break-words') - }) - }) + const threadEl = thread as HTMLElement effect.run() @@ -97,13 +246,13 @@ export async function exportToPng(fileNameFormat: string) { const passLimit = 5 const takeScreenshot = async (width: number, height: number, additionalScale = 1, currentPass = 1): Promise => { const ratio = window.devicePixelRatio || 1 - const canvas = await html2canvas(thread, { + const canvas = await html2canvas(threadEl, { scale: ratio * 2 * additionalScale, // scale up to 2x to avoid blurry images useCORS: true, scrollX: -window.scrollX, scrollY: -window.scrollY, - windowWidth: thread.scrollWidth, - windowHeight: thread.scrollHeight, + windowWidth: threadEl.scrollWidth, + windowHeight: threadEl.scrollHeight, ignoreElements: fnIgnoreElements, }) @@ -127,13 +276,13 @@ export async function exportToPng(fileNameFormat: string) { } const dataUrl = await takeScreenshot(thread.scrollWidth, thread.scrollHeight) + effect.dispose() + if (!dataUrl) { alert('Failed to export to PNG. This might be caused by the size of the conversation. Please try to export a smaller conversation.') return false } - effect.dispose() - const chatId = getChatIdFromUrl() || undefined const fileName = getFileNameWithFormat(fileNameFormat, 'png', { chatId }) downloadUrl(fileName, dataUrl) diff --git a/src/page.ts b/src/page.ts index f78bf4f..933d6f9 100644 --- a/src/page.ts +++ b/src/page.ts @@ -76,11 +76,17 @@ export function getConversationFromSharePage() { } export const conversationChoiceSelector = '.flex.justify-center span.flex-grow' +export const conversationChoiceSelectorGizmo = 'flex-grow flex-shrink-0 tabular-nums' + +export function isGizmoMode() { + return document.documentElement.classList.contains('gizmo') +} export function getConversationChoice() { // parse x from `< x / y >` to get the index of the selected response + const selector = isGizmoMode() ? conversationChoiceSelectorGizmo : conversationChoiceSelector const conversationChoices: Array = Array.from(document.querySelectorAll('main .group')) - .map(group => group.querySelector(conversationChoiceSelector)) + .map(group => group.querySelector(selector)) // non-existing element will produce null here, which will point to the last child // just in case the selector changed .map(span => Number.parseInt(span?.textContent?.trim().split(' / ')[0] ?? '0') - 1)