Last active
May 6, 2024 14:01
-
-
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`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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