Skip to content

Instantly share code, notes, and snippets.

@bric3
Last active May 6, 2024 14:01
Show Gist options
  • Save bric3/94ab68eef576f9dce1afcee4b0588054 to your computer and use it in GitHub Desktop.
Save bric3/94ab68eef576f9dce1afcee4b0588054 to your computer and use it in GitHub Desktop.
HTML `JEditor` pane for IntelliJ (and markdown code snippet with highlighting), for pre `JBHtmlPane`
import com.intellij.lang.Language
import com.intellij.lang.documentation.DocumentationSettings
import com.intellij.lang.documentation.DocumentationSettings.InlineCodeHighlightingMode.NO_HIGHLIGHTING
import com.intellij.lang.documentation.DocumentationSettings.InlineCodeHighlightingMode.SEMANTIC_HIGHLIGHTING
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.richcopy.HtmlSyntaxInfoUtil
import com.intellij.openapi.fileTypes.PlainTextLanguage
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import org.commonmark.node.Code
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Node
import org.commonmark.renderer.NodeRenderer
import org.commonmark.renderer.html.HtmlNodeRendererContext
/**
* Special commonmark renderer for code blocks.
*
* IJ has a nifty utility [HtmlSyntaxInfoUtil] that is used to copy
* selected code as HTML, it also happens this utility is used to generate
* the HTML for the code blocks in the documentation.
*
* Use this way
*
* ```kotlin
* val node = Parser.builder().build().parse(markdownContent)
* val renderer = HtmlRenderer.builder()
* .nodeRendererFactory { context -> CodeBlockNodeRenderer(project, context) }
* .build()
*
* val html = renderer.render(node)
* ```
*
* @see com.intellij.codeInsight.javadoc.JavaDocInfoGenerator.generateCodeValue
* @see com.intellij.codeInsight.javadoc.JavaDocInfoGenerator.appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet
* @see KDocRenderer StringBuilder.appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet
*/
@Suppress("UnstableApiUsage")
class CodeBlockNodeRenderer(
private val project: Project,
context: HtmlNodeRendererContext
) : NodeRenderer {
private val outputHtml = context.writer
override fun getNodeTypes() = setOf(
IndentedCodeBlock::class.java,
FencedCodeBlock::class.java,
Code::class.java
)
override fun render(node: Node) {
when (node) {
is IndentedCodeBlock -> {
renderCode(node.literal, block = true)
}
is FencedCodeBlock -> {
renderCode(node.literal, info = node.info, block = true)
}
is Code -> {
renderCode(node.literal)
}
}
}
// FIX_WHEN_MIN_IS_241 Note after 241, we may consider `com.intellij.lang.documentation.QuickDocHighlightingHelper`
// `DocumentationSettings.getMonospaceFontSizeCorrection` is going away possibly 242.
// Note that styled HTML code would then be `div.styled-code > pre`
private fun renderCode(codeSnippet: String, info: String = "", block: Boolean = false) {
outputHtml.line()
if (block) outputHtml.tag("pre")
outputHtml.tag("code style='font-size:${DocumentationSettings.getMonospaceFontSizeCorrection(true)}%;'")
val highlightingMode = if (block) {
when (DocumentationSettings.isHighlightingOfCodeBlocksEnabled()) {
true -> SEMANTIC_HIGHLIGHTING
false -> NO_HIGHLIGHTING
}
} else {
DocumentationSettings.getInlineCodeHighlightingMode()
}
outputHtml.raw(
appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet(
highlightingMode,
project,
LanguageGuesser.guessLanguage(info) ?: PlainTextLanguage.INSTANCE,
codeSnippet
)
)
outputHtml.tag("/code")
if (block) outputHtml.tag("/pre")
outputHtml.line()
}
/**
* Inspired by KDocRenderer.appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet
*/
private fun appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet(
highlightingMode: DocumentationSettings.InlineCodeHighlightingMode,
project: Project,
language: Language,
codeSnippet: String
): String {
var highlightedAndEncodedAsHtmlCodeSnippet = buildString {
when (highlightingMode) {
SEMANTIC_HIGHLIGHTING -> { // highlight code by lexer
HtmlSyntaxInfoUtil.appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet(
this,
project,
language,
codeSnippet,
false,
DocumentationSettings.getHighlightingSaturation(true)
)
}
else -> {
// raw code snippet, but escaped
append(StringUtil.escapeXmlEntities(codeSnippet))
}
}
}
if (highlightingMode != NO_HIGHLIGHTING) {
// set code text color as editor default code color instead of doc component text color
// surround by a span using the same editor colors
val codeAttributes = EditorColorsManager.getInstance()
.globalScheme
.getAttributes(HighlighterColors.TEXT)
.clone()
.apply { backgroundColor = null }
highlightedAndEncodedAsHtmlCodeSnippet = buildString {
HtmlSyntaxInfoUtil.appendStyledSpan(
this,
codeAttributes,
highlightedAndEncodedAsHtmlCodeSnippet,
DocumentationSettings.getHighlightingSaturation(true)
)
}
}
return highlightedAndEncodedAsHtmlCodeSnippet
}
}
import com.intellij.lang.documentation.DocumentationSettings
import com.intellij.openapi.editor.impl.EditorCssFontResolver
import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.BrowserHyperlinkListener
import com.intellij.ui.ColorUtil
import com.intellij.ui.JBColor
import com.intellij.ui.scale.JBUIScale.scale
import com.intellij.util.ui.ExtendableHTMLViewFactory
import com.intellij.util.ui.HTMLEditorKitBuilder
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.StyleSheetUtil
import com.intellij.util.ui.UIUtil
import com.intellij.xml.util.XmlStringUtil
import org.jetbrains.annotations.Contract
import java.awt.Dimension
import javax.swing.JEditorPane
import javax.swing.UIManager
import javax.swing.event.HyperlinkListener
/**
* Creates a [JEditorPane] tailored for HTML content with the given HTML [content] and [maxWidth].
*
* This [JEditorPane] uses the extended IntelliJ [ExtendableHTMLViewFactory] to support:
* - word wrapping
* - icons (from IJ)
* - Base 64 images
* The [extensions] parameter can be used to add more extensions to the [ExtendableHTMLViewFactory].
*
* Also, the [JEditorPane] is configured to use the [EditorCssFontResolver] to use the same font
* as the current [com.intellij.openapi.editor.Editor].
* Some rules are also added to tweak the size of some HTML elements.
*
*
* @param content the HTML content to display
* @param maxWidth the maximum width to use for the [JEditorPane.preferredSize]
* @param extensions the list of [ExtendableHTMLViewFactory.Extension] to use for the [JEditorPane]
*
* FIX_WHEN_MIN_242: Remove this and switch to JBHtmlPane
*/
fun htmlJEditorPane(
content: CharSequence,
maxWidth: Int? = null,
extensions: List<ExtendableHTMLViewFactory.Extension> = emptyList(),
hyperlinkListener: HyperlinkListener = BrowserHyperlinkListener.INSTANCE,
): JEditorPane {
return JEditorPane().apply {
// Setting the hyperlink listener **before**, so it's possible to override
// listeners installed by the editor kit, in particular HTMLEditorKitBuilder
// installs some default listeners (see : com.intellij.util.ui.JBHtmlEditorKit.install)
addHyperlinkListener(hyperlinkListener)
contentType = "text/html"
editorKit = HTMLEditorKitBuilder()
.withViewFactoryExtensions(
ExtendableHTMLViewFactory.Extensions.WORD_WRAP,
*extensions.toTypedArray()
)
.withFontResolver(EditorCssFontResolver.getGlobalInstance())
.build()
.also {
val baseFontSize = UIManager.getFont("Label.font").size
val codeFontName = EditorCssFontResolver.EDITOR_FONT_NAME_NO_LIGATURES_PLACEHOLDER
val contentCodeFontSizePercent = DocumentationSettings.getMonospaceFontSizeCorrection(true)
val paragraphSpacing = """padding: ${scale(4)}px 0 ${scale(4)}px 0"""
// Also, look at com.intellij.codeInsight.documentation.DocumentationHtmlUtil::getDocumentationPaneAdditionalCssRules
it.styleSheet.addStyleSheet(
StyleSheetUtil.loadStyleSheet(
"""
h6 { font-size: ${baseFontSize + 1}}
h5 { font-size: ${baseFontSize + 2}}
h4 { font-size: ${baseFontSize + 3}}
h3 { font-size: ${baseFontSize + 4}}
h2 { font-size: ${baseFontSize + 6}}
h1 { font-size: ${baseFontSize + 8}}
h1, h2, h3, h4, h5, h6 {margin: 0 0 0 0; $paragraphSpacing; }
p { margin: 0 0 0 0; $paragraphSpacing; line-height: 125%; }
ul { margin: 0 0 0 ${scale(10)}px; $paragraphSpacing;}
ol { margin: 0 0 0 ${scale(20)}px; $paragraphSpacing;}
li { padding: ${scale(1)}px 0 ${scale(2)}px 0; }
li p { padding-top: 0; padding-bottom: 0; }
hr {
padding: ${scale(1)}px 0 0 0;
margin: ${scale(4)}px 0 ${scale(4)}px 0;
border-bottom: ${scale(1)}px solid ${ColorUtil.toHtmlColor(UIUtil.getTooltipSeparatorColor())};
width: 100%;
}
code, pre, .pre { font-family:"$codeFontName"; font-size:$contentCodeFontSizePercent%; }
a { color: ${ColorUtil.toHtmlColor(JBUI.CurrentTheme.Link.Foreground.ENABLED)}; text-decoration: none; }
""".trimIndent()
)
)
}
UIUtil.doNotScrollToCaret(this)
caretPosition = 0
isEditable = false
foreground = JBColor.foreground()
isOpaque = false
text = colorizeSeparators(content.toString())
maxWidth?.let {
fitContent(maxWidth)
}
}
}
/**
* Properly resize the [JEditorPane] to fit the content vertically.
* [JEditorPane] cannot resize both vertically and horizontally at the same time,
* it is necessary to use [JEditorPane.setSize] to give the **width constraint**, and then
* make [JEditorPane.preferredSize] have the computed **height constraint** using [maxWidth].
*
* @receiver the [JEditorPane] to resize
* @param maxWidth the maximum width to use for the [JEditorPane.preferredSize]
*/
private fun JEditorPane.fitContent(maxWidth: Int) {
fun calculateSize() {
// Reset the preferred size to force UI calculation
preferredSize = null
// If the preferred width is already smaller than the max width,
// there's no need to try to fit the content
if (preferredSize.width < maxWidth) return
// Sets the size so that the JEditorPane can compute the preferred height
setSize(maxWidth, Short.MAX_VALUE.toInt())
// Updates the preferred width with the new width
preferredSize = Dimension(maxWidth, preferredSize.height)
// Setting the size again, so that it has the updated width and height
size = preferredSize
}
this.addPropertyChangeListener {
when (it.propertyName) {
// Messing with the border modifies the insets, so we need to re-run the layout engine since inset
// modifications leads to word wrapping changes which can cause rendering underflow;
// thus we need to re-do everything
"border",
"document" -> {
calculateSize()
}
}
}
calculateSize()
}
// Copied from com.intellij.codeInsight.hint.LineTooltipRenderer.colorizeSeparators
// Java text components don't support specifying color for 'hr' tag, so we need to replace it with something else,
// if we need a separator with custom color
@Contract(pure = true)
private fun colorizeSeparators(html: String): String {
val body = UIUtil.getHtmlBody(html)
val parts = StringUtil.split(body, UIUtil.BORDER_LINE, true, false)
if (parts.size <= 1) {
return html
}
val b = StringBuilder()
for (part in parts) {
val addBorder = b.isNotEmpty()
b.append("<div")
if (addBorder) {
b.append(" style='margin-top:6; padding-top:6; border-top: thin solid #")
.append(ColorUtil.toHex(UIUtil.getTooltipSeparatorColor()))
.append("'")
}
b.append("'>").append(part).append("</div>")
}
return XmlStringUtil.wrapInHtml(b.toString())
}
import com.intellij.lang.Language
import com.intellij.lang.LanguageUtil
import java.util.*
/**
* Guesses the available IntelliJ [Language] of a fenced code block based on the language name.
*
* [Language.findLanguageByID] is not used it returns [Language.ANY] for unknown/undefined languages
* which makes [com.intellij.openapi.editor.richcopy.HtmlSyntaxInfoUtil.appendHighlightedByLexerAndEncodedAsHtmlCodeSnippet]
* fails.
*
* Also, it is pretty common for markdown to use language identifier that not always match
* the language id used by IntelliJ.
*
* Inspired by https://github.com/asciidoctor/asciidoctor-intellij-plugin/blob/3ac99c53b21bc5d0ecb4961dc5d9c2095c3ea342/src/main/java/org/asciidoc/intellij/injection/LanguageGuesser.java
*/
object LanguageGuesser {
private val langToLanguageMap: Map<String, Language> = buildMap {
for (language in LanguageUtil.getInjectableLanguages()) {
val languageInfoId = language.id.lowercase(Locale.US).replace(" ", "")
this[languageInfoId] = language
}
associateIfAvailable("js", "javascript")
associateIfAvailable("bash", "shellscript")
associateIfAvailable("shell", "shellscript")
}
fun guessLanguage(languageName: String?): Language? {
if (languageName.isNullOrBlank()) {
return null
}
return langToLanguageMap[languageName.lowercase(Locale.US)]
}
private fun MutableMap<String, Language>.associateIfAvailable(newLanguageKey: String, existingLanguageKey: String) {
@Suppress("UNCHECKED_CAST")
(this as MutableMap<String, Language?>).computeIfAbsent(newLanguageKey) { this[existingLanguageKey] }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment