Skip to content

Instantly share code, notes, and snippets.

@ngseke
Last active May 4, 2024 08:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ngseke/ed21fe10b575aae93746579e0139c068 to your computer and use it in GitHub Desktop.
Save ngseke/ed21fe10b575aae93746579e0139c068 to your computer and use it in GitHub Desktop.
ChatGPT as An English Tutor
// ==UserScript==
// @name ChatGPT as An English Tutor
// @namespace https://ngseke.me/
// @version 0.1
// @description ChatGPT as An English Tutor
// @author ngseke
// @match https://chatgpt.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com
// @grant none
// ==/UserScript==
(function () {
const blank = '_'.repeat(5)
const sentences = [
`幫我翻譯成中文並分析 collocations, phrases, sentence patterns:\n ${blank}`,
`幫我翻譯成中文:\n ${blank}`,
`${blank} 是什麼意思?請提供一些英文例句和對應的翻譯`,
`請提供一些以下在英文的表達方式:\n${blank}`,
`請問以下英文聽起來自然且通順嗎?若不通順請提供一些修改建議;若通順也請提供其他類似的說法:\n${blank}`,
`請問在英文當中有 ${blank} 這種說法嗎?`,
`${blank} 和 ${blank} 的差別是什麼?`,
`請提供一些 ${blank} 的英文同義詞`,
`請提供其他類似的英文說法: ${blank}`,
`請多解釋一點 ${blank}`,
`其中的 ${blank} 是什麼意思?它在英文中的用法或文法是什麼?`,
`什麼是 ${blank}`,
]
const debounce = (callback, wait) => {
let timerId = null
return (...args) => {
clearTimeout(timerId)
timerId = setTimeout(() => callback.apply(null, args), wait)
}
}
const namespace = `english-tutor-${crypto.randomUUID()}`
const hideClassName = 'hide'
const getTextArea = () => document.querySelector('.absolute.bottom-0 textarea') ?? document.querySelector('.border textarea')
const getContainer = () => getTextArea()?.parentNode?.parentNode
const getClipboard = () => navigator.clipboard.readText()
const insertText = async (text) => {
const textarea = getTextArea()
textarea.focus()
const textWithClipboard = text.replace(blank, await getClipboard())
textarea.value = `${textWithClipboard}`
textarea.dispatchEvent(new Event('input', { bubbles: true }))
textarea.scrollTop = textarea.scrollHeight
}
const injectStyles = () => {
const css = `
.${namespace} {
position: absolute;
left: 2.5rem;
right: 1rem;
bottom: 1rem;
z-index: 1;
pointer-events: none;
}
.${namespace} > * {
pointer-events: auto;
}
.${namespace} button.activator {
display: flex;
cursor: pointer;
font-size: 2rem;
line-height: 1;
transition: all .5s;
user-select: none;
text-shadow: 0 0 10px rgba(255, 255, 0, .3);
}
.${namespace} button.activator:hover {
text-shadow: 0 0 10px rgba(255, 255, 0, .5);
}
.${namespace} .${hideClassName} {
display: none;
}
.${namespace} ul {
display: flex;
flex-direction: column;
padding: .5rem 0;
gap: .25rem;
font-size: 14px;
position: absolute;
top: 0;
left: 0;
transform: translateY(calc(-100% - .5rem));
border-radius: .5rem;
width: 35rem;
max-width: 100%;
background-color: rgb(32,33,35);
box-shadow: rgba(0, 0, 0, 0.25) 0px 25px 50px -12px;
}
.${namespace} li {
user-select: none;
}
.${namespace} li a {
display: block;
padding: .25rem 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.${namespace} li:hover {
background-color: rgba(255, 255, 255, .1)
}
#prompt-textarea {
padding-left: 5rem;
}
@media (max-width: 767.99px) {
#prompt-textarea {
padding-left: 3rem;
}
}
`
const style = document.createElement('style')
document.body.appendChild(style)
style.type = 'text/css'
style.appendChild(document.createTextNode(css))
}
const generateMenu = () => {
const ul = document.createElement('ul')
ul.classList.add(hideClassName)
const lis = sentences.map((sentence) => {
const li = document.createElement('li')
const a = document.createElement('a')
a.href = '#'
a.innerText = sentence.replace('\n', '')
a.addEventListener('click', () => {
insertText(sentence)
ul.classList.add(hideClassName)
})
li.appendChild(a)
return li
})
ul.append(...lis)
return ul
}
const generateActivatorButton = (target) => {
const $button = document.createElement('button')
$button.innerHTML = '🪄'
$button.classList.add('activator')
$button.type = 'button'
let triggerType = 'click'
;['click', 'mouseover'].forEach((type) => {
$button.addEventListener(type, (e) => {
const shouldSkip = triggerType === 'click' &&
!target.classList.contains(hideClassName)
if (shouldSkip) return
triggerType = e.type
target.classList.remove(hideClassName)
})
})
const handleHoverOutside = debounce((e) => {
const isOutside = ![target, $button].some($el => $el.contains(e.target))
if (isOutside && triggerType === 'mouseover') target.classList.add(hideClassName)
}, 100)
document.body.addEventListener('mouseover', handleHoverOutside)
return $button
}
const listenClickOutside = ($button, $menu) => {
document.addEventListener('click', (event) => {
const withinBoundaries = [$button, $menu].some($el => (
event.composedPath().includes($el)
))
if (withinBoundaries) return
$menu.classList.add(hideClassName)
})
}
const injectWrapper = () => {
const $wrapper = document.createElement('div')
$wrapper.classList.add(namespace)
const $menu = generateMenu()
const $activatorButton = generateActivatorButton($menu)
$wrapper.appendChild($menu)
$wrapper.appendChild($activatorButton)
const $container = getContainer()
$container?.prepend($wrapper)
listenClickOutside($activatorButton, $menu)
return { $menu }
}
const observeDom = () => {
const observer = new MutationObserver(() => {
if (!getContainer() || getContainer()?.querySelector(`.${namespace}`)) {
return
}
injectWrapper()
})
observer.observe(document.body, { childList: true, subtree: true })
}
injectStyles()
observeDom()
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment