// ==UserScript==
// @name Discourse Callout 建议 (Callout Suggestions)
// @namespace https://github.com/stevessr/bug-v3
// @version 1.2.1
// @description 为 Discourse 论坛添加 Markdown Callout 自动建议功能 (Add Markdown callout autocomplete to Discourse forums)
// @author stevessr
// @match https://linux.do/*
// @match https://meta.discourse.org/*
// @match https://*.discourse.org/*
// @match http://localhost:5173/*
// @exclude https://linux.do/a/*
// @match https://idcflare.com/*
// @grant none
// @license MIT
// @homepageURL https://github.com/stevessr/bug-v3
// @supportURL https://github.com/stevessr/bug-v3/issues
// @run-at document-end
// @downloadURL https://update.greasyfork.icu/scripts/554977/Discourse%20Callout%20%E5%BB%BA%E8%AE%AE%20%28Callout%20Suggestions%29.user.js
// @updateURL https://update.greasyfork.icu/scripts/554977/Discourse%20Callout%20%E5%BB%BA%E8%AE%AE%20%28Callout%20Suggestions%29.meta.js
// ==/UserScript==
(function () {
'use strict'
// ===== Settings Management =====
const SETTINGS_KEY = 'emoji_extension_userscript_settings'
function loadSettings() {
try {
const settingsData = localStorage.getItem(SETTINGS_KEY)
if (settingsData) {
const settings = JSON.parse(settingsData)
return settings
}
} catch (e) {
console.warn('[Callout Suggestions] Failed to load settings:', e)
}
return {}
}
// Check forceMobileMode setting - if enabled, script respects it
function shouldRespectForceMobileMode() {
const settings = loadSettings()
return settings.forceMobileMode === true
}
// ===== Callout Keywords =====
const da = document.addEventListener
const calloutKeywords = [
'note',
'abstract',
'summary',
'tldr',
'info',
'todo',
'tip',
'hint',
'success',
'check',
'done',
'question',
'help',
'faq',
'warning',
'caution',
'attention',
'failure',
'fail',
'missing',
'danger',
'error',
'bug',
'example',
'quote',
'cite'
].sort()
// ===== Icon Definitions =====
const ICONS = {
info: {
icon: 'ℹ️',
color: 'rgba(2, 122, 255, 0.1)',
svg: ''
},
tip: {
icon: '💡',
color: 'rgba(0, 191, 188, 0.1)',
svg: ''
},
faq: {
icon: '❓',
color: 'rgba(236, 117, 0, 0.1)',
svg: ''
},
question: {
icon: '🤔',
color: 'rgba(236, 117, 0, 0.1)',
svg: ''
},
note: {
icon: '📝',
color: 'rgba(8, 109, 221, 0.1)',
svg: ''
},
abstract: {
icon: '📋',
color: 'rgba(0, 191, 188, 0.1)',
svg: ''
},
todo: {
icon: '☑️',
color: 'rgba(2, 122, 255, 0.1)',
svg: ''
},
success: {
icon: '🎉',
color: 'rgba(68, 207, 110, 0.1)',
svg: ''
},
warning: {
icon: '⚠️',
color: 'rgba(236, 117, 0, 0.1)',
svg: ''
},
failure: {
icon: '❌',
color: 'rgba(233, 49, 71, 0.1)',
svg: ''
},
danger: {
icon: '☠️',
color: 'rgba(233, 49, 71, 0.1)',
svg: ''
},
bug: {
icon: '🐛',
color: 'rgba(233, 49, 71, 0.1)',
svg: ''
},
example: {
icon: '🔎',
color: 'rgba(120, 82, 238, 0.1)',
svg: ''
},
quote: {
icon: '💬',
color: 'rgba(158, 158, 158, 0.1)',
svg: ''
}
}
const ALIASES = {
summary: 'abstract',
tldr: 'abstract',
hint: 'tip',
check: 'success',
done: 'success',
help: 'faq',
caution: 'warning',
attention: 'warning',
fail: 'failure',
missing: 'failure',
error: 'danger',
cite: 'quote'
}
const DEFAULT_ICON = {
icon: '📝',
color: 'var(--secondary-low)',
svg: ''
}
function getIcon(key) {
const alias = ALIASES[key]
const iconKey = alias || key
return ICONS[iconKey] || DEFAULT_ICON
}
// ===== Suggestion Box =====
let suggestionBox = null
let activeSuggestionIndex = 0
function createSuggestionBox() {
if (suggestionBox) return
suggestionBox = document.createElement('div')
suggestionBox.id = 'callout-suggestion-box'
document.body.appendChild(suggestionBox)
injectStyles()
}
function injectStyles() {
const id = 'callout-suggestion-styles'
if (document.getElementById(id)) return
const style = document.createElement('style')
style.id = id
style.textContent = `
#callout-suggestion-box {
position: absolute;
background-color: var(--secondary);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
z-index: 999999;
padding: 5px;
display: none;
font-size: 14px;
max-height: 200px;
overflow-y: auto;
}
.callout-suggestion-item {
padding: 8px 12px;
cursor: pointer;
color: var(--primary-high);
border-radius: 4px;
display: flex;
align-items: center;
}
.callout-suggestion-item:hover, .callout-suggestion-item.active {
background-color: var(--primary-low) !important;
}
`
document.documentElement.appendChild(style)
}
function hideSuggestionBox() {
if (suggestionBox) suggestionBox.style.display = 'none'
}
function updateActiveSuggestion() {
if (!suggestionBox) return
const items = suggestionBox.querySelectorAll('.callout-suggestion-item')
items.forEach((it, idx) => {
it.classList.toggle('active', idx === activeSuggestionIndex)
if (idx === activeSuggestionIndex) it.scrollIntoView({ block: 'nearest' })
})
}
function applyCompletion(element, selectedKeyword) {
if (element instanceof HTMLTextAreaElement) {
// Handle textarea
const text = element.value
const selectionStart = element.selectionStart || 0
const textBeforeCursor = text.substring(0, selectionStart)
let triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('【')
if (triggerIndex === -1) return
const newText = `[!${selectedKeyword}]`
const textAfter = text.substring(selectionStart)
element.value = textBeforeCursor.substring(0, triggerIndex) + newText + textAfter
const newCursorPos = triggerIndex + newText.length
element.selectionStart = element.selectionEnd = newCursorPos
element.dispatchEvent(new Event('input', { bubbles: true }))
} else if (element.classList && element.classList.contains('ProseMirror')) {
// Handle ProseMirror
const newText = `[!${selectedKeyword}]`
try {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const textBeforeCursor =
range.startContainer.textContent && range.startContainer.textContent.substring(0, range.startOffset) || ''
let triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('【')
if (triggerIndex === -1) return
const deleteRange = document.createRange()
deleteRange.setStart(range.startContainer, triggerIndex)
deleteRange.setEnd(range.startContainer, range.startOffset)
deleteRange.deleteContents()
const textNode = document.createTextNode(newText)
deleteRange.insertNode(textNode)
const newRange = document.createRange()
newRange.setStartAfter(textNode)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
element.dispatchEvent(new Event('input', { bubbles: true }))
} catch (e) {
console.error('[Callout Suggestions] ProseMirror completion failed', e)
}
}
}
function getCursorXY(element, position) {
if (element instanceof HTMLTextAreaElement) {
const mirrorId = 'callout-textarea-mirror-div'
let mirror = document.getElementById(mirrorId)
const rect = element.getBoundingClientRect()
if (!mirror) {
mirror = document.createElement('div')
mirror.id = mirrorId
document.body.appendChild(mirror)
}
const style = window.getComputedStyle(element)
const props = [
'boxSizing',
'fontFamily',
'fontSize',
'fontWeight',
'letterSpacing',
'lineHeight',
'textTransform',
'textAlign',
'direction',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth'
]
const ms = mirror.style
props.forEach(p => {
ms[p] = style.getPropertyValue(p)
})
ms.position = 'absolute'
ms.left = `${rect.left + window.scrollX}px`
ms.top = `${rect.top + window.scrollY}px`
ms.width = `${rect.width}px`
ms.height = `${rect.height}px`
ms.overflow = 'hidden'
ms.visibility = 'hidden'
ms.whiteSpace = 'pre-wrap'
ms.wordWrap = 'break-word'
ms.boxSizing = style.getPropertyValue('box-sizing') || 'border-box'
const cursorPosition = position !== undefined ? position : element.selectionEnd
const textUpToCursor = element.value.substring(0, cursorPosition)
mirror.textContent = textUpToCursor
const span = document.createElement('span')
span.textContent = '\u200b'
mirror.appendChild(span)
const spanRect = span.getBoundingClientRect()
const offsetX = span.offsetLeft - element.scrollLeft
const offsetY = span.offsetTop - element.scrollTop
return {
x: spanRect.left + window.scrollX,
y: spanRect.top + window.scrollY,
bottom: spanRect.bottom + window.scrollY,
offsetX,
offsetY
}
} else {
// Handle ProseMirror
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
const rect = element.getBoundingClientRect()
return {
x: rect.left + window.scrollX,
y: rect.top + window.scrollY,
bottom: rect.bottom + window.scrollY,
offsetX: 0,
offsetY: 0
}
}
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
return {
x: rect.left + window.scrollX,
y: rect.top + window.scrollY,
bottom: rect.bottom + window.scrollY,
offsetX: 0,
offsetY: 0
}
}
}
function updateSuggestionBox(element, matches, triggerIndex) {
if (!suggestionBox || matches.length === 0) {
hideSuggestionBox()
return
}
suggestionBox.innerHTML = matches
.map((keyword, index) => {
const iconData = getIcon(keyword)
const backgroundColor = iconData.color || 'transparent'
const iconColor = iconData.color
? iconData.color.replace('rgba', 'rgb').replace(/, [0-9.]+\)/, ')')
: 'var(--primary-medium)'
const coloredSvg = (iconData.svg || DEFAULT_ICON.svg).replace(
'