importing markdown label

This commit is contained in:
betalars 2024-10-07 11:10:58 +02:00
parent 23eedfd167
commit 355fbc6939
15 changed files with 1528 additions and 0 deletions

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Daenvil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,289 @@
# MarkdownLabel
A custom [Godot](https://godotengine.org/) node that extends [RichTextLabel](https://docs.godotengine.org/en/stable/classes/class_richtextlabel.html) to use Markdown instead of BBCode.
### Contents
- [Disclaimer](#disclaimer)
- [Installation](#installation)
- [Usage](#usage)
- [Basic syntax](#basic-syntax)
- [Code](#code)
- [Headers](#headers)
- [Links](#links)
- [Images](#images)
- [Lists](#lists)
- [Task list items (checkboxes)](#task-list-items)
- [Tables](#tables)
- [Escaping characters](#escaping-characters)
- [Advanced usage](#advanced-usage)
- [Limitations](#limitations)
- [Unsupported syntax elements](#unsupported-syntax-elements)
- [Performance](#performance)
- [Acknowledgements](#acknowledgements)
## Disclaimer
**This is a work in progress**. I created this for my own use and figured out someone else might as well have some use for it. Obviously using BBCode will be better performance-wise since it's natively integrated in Godot. But using Markdown is much easier to write and read, so it can save development time in many cases.
I coded this quickly and without previous knowledge of how to parse Markdown properly, so there might be some inefficiencies and bugs. Please report any unexpected behavior.
I might convert this to C++ code at some point, to improve performance.
### Intended use case
This node is very useful for static text that you want to display in your application. It's not recommended to use this for text which is dynamically modified at run time.
My initial use case that lead me to do this was to directly include text from files in my game, such as credits and patch notes, in a format that is easier to mantain for me. This has the added benefit of being able to use the same Markdown files that are displayed in a github repository, instead of having to make two versions of the same text in two different formats.
## Installation
1. Download the `addons` folder of this repository.
2. Place it in your project's root folder.
3. Go to `Project > Project Settings... > Plugins` and enable the MarkdownLabel plugin.
4. Reload the project.
## Usage
Simply add a MarkdownLabel to the scene and write its `markdown_text` field in Markdown format. Alternatively, you can use the ``display_file`` method to automatically import the contents of a Markdown file.
In the RichTextLabel properties:
- Do not touch neither the `bbcode_enabled` nor the `text` property, since they are internally used by MarkdownLabel to properly format its text. Both properties are hidden from the editor to prevent mistakenly editing them.
- You can use the rest of its properties as normal.
You can still use BBCode tags that don't have a Markdown equivalent, such as `[color=green]underlined text[/color]`, allowing you to have the full functionality of RichTextLabel with the simplicity and readibility of Markdown.
![An example of the node being used to display this Markdown file](addons/markdownlabel/assets/screenshot.png "An example of the node being used to display this Markdown file")
*An example of the node being used to display this Markdown file.*
### Basic syntax
The basic Markdown syntax works in the standard way:
```
Markdown text ................ -> BBCode equivalent
-------------------------------||------------------
**Bold** or __bold__ ......... -> [b]Bold[/b] or [b]bold[/b]
*Italics* or _italics_ ....... -> [i]Italics[/i] or [i]italics[/i]
***Nested*** *__emphasis__* .. -> [b][i]Nested[/i][b] [i][b]emphasis[/b][/i]
~~Strike-through~~ ........... -> [s]Strike-through[/s]
```
### Code
You can display code in-line by surrounding text with any number of backticks (\`), and you can display code in multiple lines (also called a fenced code block) by placing a line containing just three or more backticks (\`\`\`) or tildes (\~\~\~) above and below your code block.
Examples:
```
Markdown text ................. -> BBCode equivalent
--------------------------------||------------------
The following is `in-line code` -> The following is [code]in-line code[/code]
This is also ``in-line code`` -> The following is [code]in-line code[/code]
~~~ .......... -> [code]
This is a .......... -> This is a
multiline codeblock .......... -> multiline codeblock
~~~ .......... -> [/code]
```
**Important**: note that in-line code and code blocks won't do anything with Godot's default font, since it doesn't have a monospace variant. As described in [Godot's BBCode reference](https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html#reference): "The monospaced (`[code]`) tag only works if a custom font is set up in the RichTextLabel node's theme overrides. Otherwise, monospaced text will use the regular font".
### Headers
MarkdownLabel supports headers, although RichTextLabel doesn't. By default, a line defined as a header will have its font size scaled by a pre-defined amount.
To define a line as a header, begin it with any number of consecutive hash symbols (#) and follow it with the title of your header. The number of hash symbols defines the level of the header. The maximum supported level is six.
Example:
```
Markdown text:
## This is a second-level header
BBCode equivalent:
[font_size=27]This is a second-level header[/font_size]
```
where the `27` in `[font_size=27]` comes from multiplying the set `h2.font_size` (`1.714` by default) by the current `normal_font_size` (`16` by default).
You can optionally set custom sizes and formatting (bold, italics, underline, and color) for each header level individually. To do so:
- In the inspector, open the "Header formats" category, click on the resource associated with the desired header level, and customize the properties there.
- In script, access those properties through the `h1`, `h2`, etc. properties. Example: `$YourMarkdownLabel.h3.is_italic = true` will set all level-3 headers within `$YourMarkdownLabel` to be displayed as italics.
Note: to change a header level's font color, it's not enough with changing the ``font_color`` property: you also have to set its ``override_font_color`` property to ``true``.
Of course, you can also use basic formatting within the headers (e.g. `### Header with **bold** and *italic* words`).
### Links
Links follow the standard Markdown syntax of `[text to display](https://example.com)`. Additionally, you can add tooltips to your links with `[text to display](https://example.com "Some tooltip")`.
"Autolinks" are also supported with their standard syntax: `<https://example.com>`, and `<mail@example.com>` for mail autolinks.
Links created this way will be automatically handled by MarkdownLabel, implementing their expected behaviour:
- Valid header anchors (such as the ones in [Contents](#contents)) will make MarkdownLabel scroll to their header's position.
- Valid URLs and emails will be opened according to the user's settings (usually, using their default browser).
- Links that do not match any of the above conditions will be interpreted as a URL by prefixing them with "https://". E.g. `[link](example.com)` will link to "https://example.com". This can be disabled using the ``assume_https_links`` property (enabled by default), in which case the ``unhandled_link_clicked`` signal will be emitted.
This behavior can be disabled using the `automatic_links` property (enabled by default), in which case all links will be left unhandled and the ``unhandled_link_clicked`` signal will be emitted for all of them.
The ``unhandled_link_clicked`` signal can be used to implement custom behavior when clicking a link. It passes the clicked link metadata (which usually would be the URL) as an argument.
```
Markdown text .............................. -> BBCode equivalent
---------------------------------------------||------------------
[this is a link](https://example.com) .............. -> [url=https://example.com]this is a link[/url]
[this is a link](https://example.com "Example page") -> [hint=Example url][url=https://example.com]this is a link[/url][/hint]
<https://example.com> .............................. -> [url]https://example.com[/url]
<mail@example.com> ................................. -> [url=mailto:mail@example.com]mail@example.com[/url]
```
### Images
Images use the same syntax as links but preceded by an exclamation mark (!):
```
Markdown text .............................................. -> BBCode equivalent
-------------------------------------------------------------||------------------
![This is an image](res://some/path.png) ................... -> [img]res://some/path.png[/img]
![This is an image](res://some/path.png "This is a tooltip") -> [hint=This is a tooltip][img]res://some/path.png[/img][/hint]
```
However, Godot's BBCode doesn't support alt text for images, so what you put inside the square brackets doesn't affect the end result. You can use it for your own clarity, though.
For advanced usage (setting width, height, and other options), use the BBCode `[img]` tag instead, as described in [Godot's BBCode reference](https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html#reference).
### Lists
Unordered list elements begin with a dash (-), asterisk (*), or plus sign (+) followed by a space.
Ordered list elements begin with a number from 1 to 9 followed by a single dot and a space.
To begin a list, you must write the first element without indentation and, in the case of ordered lists, the first element must begin with the number 1.
From there, you add elements in consecutive lines (do not leave blank lines between elements), and you can open nested lists by indenting new elements any number of spaces or tabs.
Examples:
Markdown text:
```
1. First element of an unordered list
2. Second element
1. Nested element
1. Third element. The number at the beginning doesn't need to match the actual order. It's only relevant for the first element.
- You can also nest unordered lists inside ordered lists, and viceversa
1. This is a nested list inside another nested list.
```
BBCode equivalent:
```
[ol]First element of an unordered list
Second element
[ol]Nested element[/ol]
Third element. The number at the beginning doesn't need to match the actual order. It's only relevant for the first element.
[ul]You can also nest unordered lists inside ordered lists, and viceversa
[ol]This is a nested list inside another nested list.[/ol]
[/ul][/ol]
```
#### Task list items
A task list item is an unordered list item which begins with ``[ ]`` or ``[x]`` followed by a space. These characters are, by default, replaced with a checkbox icon when converting the text (☐ and ☑, respectively). These checkbox characters depend on the used font and may not display properly, so they can be customized using the ``unchecked_item_character`` and ``checked_item_character`` properties, where you can even insert an image using BBCode or Markdown syntax.
When clicking on a checkbox, it automatically checks/unchecks itself and emits the ``task_checkbox_clicked`` signal. This behavior can be disabled with the ``enable_checkbox_clicks`` property.
The arguments of the ``task_checkbox_clicked`` signal are:
- The id of the checkbox (used internally)
- The line number it is on (within the original Markdown text)
- A boolean representing whether the checkbox is now checked (true) or unchecked (false)
- A string containing the text after the checkbox (within the same line).
Example (run the ``example.tscn`` scene to test it):
- [ ] This is an unchecked item
- [x] This is a nested task
- [x] This is a checked item
1. This is a nested regular list
2. Here goes another nested task list:
- [ ] Task 1
- [ ] Task 2
### Tables
Tables are constructed by separating columns with pipes (`|`).
Example:
Markdown text:
````
| cell1 | cell2 |
| cell3 | cell4 |
````
BBCode equivalent:
````
[table=2]
[cell]cell1[/cell][cell]cell2[/cell]
[cell]cell3[/cell][cell]cell4[/cell]
[/table]
````
Note that [delimiter rows](https://github.github.com/gfm/#delimiter-row) are optional and will be ignored, since Godot's BBCode doesn't support cell alignment.
Example:
````
| cell1 | cell2 |
| ----: | :---- |
| cell3 | cell4 |
````
The above Markdown table will produce the same BBCode output as the previous example.
For advanced usage (setting ratio, border, background, etc.), use the BBCode `[table]` tag instead, as described in [Godot's BBCode reference](https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html#reference).
### Escaping characters
You can escape characters using a backlash if you don't want them to form a Markdown syntax element. You can escape backlashes if you don't want them to escape the following character. You can't escape characters inside in-line or fenced code, since the string will be displayed as-is. You also don't need to escape characters inside a link or image url.
Examples:
```
Markdown text ............................ -> BBCode equivalent
-------------------------------------------||------------------
These \**outer asterisks*\* are escaped .. -> These *[i]outer asterisks[/i]* are escaped
This \\*asterisk* is not escaped ......... -> \[i]This asterisk[/i] is not escaped
`This \\*asterisk* is inside in-line code` -> [code]This \\*asterisk* is inside in-line code[/code]
[Link](url_with_backlashes.net) .......... -> [url=url_with_backlashes.net]Link[/url]
```
Note: to escape an ordered list, you must escape the dot that follows the number, e.g. `1\. Not a list`.
Keep in mind that, if you are writing text inside a script, you will have to "double escape" backlashes, since you are writing in a string. Some other characters, such as double-quotes, also need in-script escaping:
- In-script: `\\*`, `\\\"`
- In-editor: `\*`, `\"`
- Result: `*`, `"`
### Advanced usage
MarkdownLabel can be customized to handle custom syntax if desired. There are two methods which are meant to support this use case: ``_preprocess_line()`` and ``_process_custom_syntax()``. These are called line by line and do nothing by default. ``_preprocess_line()`` is called before any syntax in the line is processed by the node, while ``_process_custom_syntax()`` is called after all syntax has been processed. These methods take a line as argument and return a processed line. This way, you can create a node that inherits from MarkdownLabel and override these methods in order to implement your custom syntax.
For even more advanced customization, you can override other built-in methods, like ``_process_text_formatting_syntax()`` or ``_process_link_syntax()``. Check the source code for more information.
## Limitations
Keep in mind that this is not supposed to be a full Markdown implementation, it just provides a Markdown interface to Godot's BBCode support and, as such, is limited by it.
If encountering any unreported bug or unexpected bahaviour, please ensure that your Markdown is written as clean as possible, following best practices (I wrote this primarily taking [Commonmark](https://commonmark.org/) and [Github-flavoured Markdown](https://github.github.com/gfm/) as reference, but it has its own peculiarities due to the use of Godot's BBCode).
### Unsupported syntax elements
The following Markdown syntax elements are not supported because Godot's BBCode does not support them:
- Quotes
- Horizontal rules
- Reference links
### Performance
This node basically parses the whole text, converting it from Markdown to BBCode at runtime, so it may produce performance issues with some extreme usages, such as very large and heavily-formatted texts or updating a heavily-formatted text very frequently. That already can happen with BBCode, though, so in that case, you are probably better off [using RichTextLabel's functions](https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html#using-push-tag-and-pop-functions-instead-of-bbcode) instead of writing the formatting directly in-text.
### Acknowledgements
The syntax and implementation of MarkdownLabel is largely based on [Github-flavored Markdown](https://github.github.com/gfm/) and [CommonMark](https://commonmark.org/), with its own quirks to accomodate it within [Godot's RichTextLabel BBCode](https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html).

View File

@ -0,0 +1,9 @@
extends Control
func _ready() -> void:
$MarkdownLabel.display_file("res://addons/markdownlabel/README.md")
$MarkdownLabel.task_checkbox_clicked.connect(
func(id: int, line: int, checked: bool, text: String) -> void:
print("%s task #%d on line %d: %s" % ["Checked" if checked else "Unchecked", id, line, text])
)

View File

@ -0,0 +1,89 @@
[gd_scene load_steps=15 format=3 uid="uid://bka0d50qmnb8y"]
[ext_resource type="Script" path="res://addons/markdownlabel/example.gd" id="1_7b8dd"]
[ext_resource type="Script" path="res://addons/markdownlabel/markdownlabel.gd" id="2_opcio"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h1_format.gd" id="3_kbjha"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h2_format.gd" id="4_tqhuu"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h3_format.gd" id="5_us0p7"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h4_format.gd" id="6_8ublj"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h5_format.gd" id="7_42de6"]
[ext_resource type="Script" path="res://addons/markdownlabel/header_formats/h6_format.gd" id="8_y8fds"]
[sub_resource type="Resource" id="Resource_r7ev3"]
script = ExtResource("3_kbjha")
font_size = 2.285
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[sub_resource type="Resource" id="Resource_qh6ic"]
script = ExtResource("4_tqhuu")
font_size = 1.714
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[sub_resource type="Resource" id="Resource_qx73p"]
script = ExtResource("5_us0p7")
font_size = 1.428
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[sub_resource type="Resource" id="Resource_yx0wh"]
script = ExtResource("6_8ublj")
font_size = 1.142
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[sub_resource type="Resource" id="Resource_1ovcl"]
script = ExtResource("7_42de6")
font_size = 1.0
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[sub_resource type="Resource" id="Resource_fj0e0"]
script = ExtResource("8_y8fds")
font_size = 0.857
is_bold = false
is_italic = false
is_underlined = false
override_font_color = false
font_color = Color(1, 1, 1, 1)
[node name="Example" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_7b8dd")
[node name="MarkdownLabel" type="RichTextLabel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
bbcode_enabled = true
script = ExtResource("2_opcio")
h1 = SubResource("Resource_r7ev3")
h2 = SubResource("Resource_qh6ic")
h3 = SubResource("Resource_qx73p")
h4 = SubResource("Resource_yx0wh")
h5 = SubResource("Resource_1ovcl")
h6 = SubResource("Resource_fj0e0")

View File

@ -0,0 +1,44 @@
class_name H1Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 2.285 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,44 @@
class_name H2Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 1.714 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,44 @@
class_name H3Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 1.428 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,44 @@
class_name H4Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 1.142 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,44 @@
class_name H5Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 1 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,44 @@
class_name H6Format
extends Resource
## Relative font size of this header level (will be multiplied by [code]normal_font_size[/code])
@export var font_size: float = 0.857 : set = _set_font_size
## Whether this header level is drawn as bold or not
@export var is_bold := false : set = _set_is_bold
## Whether this header level is drawn as italics or not
@export var is_italic := false : set = _set_is_italic
## Whether this header level is underlined or not
@export var is_underlined := false : set = _set_is_underlined
## When enabled, you can override the color of this header level with a custom color. If disabled, uses the same color as the rest of the text.
@export var override_font_color: bool = false : set = _set_override_font_color
## Custom font color to apply to this header level. Ignored if [code]override_font_color[/code] is disabled.
@export var font_color: Color = Color.WHITE : set = _set_font_color
signal _updated
func _init() -> void:
resource_local_to_scene = true
func _set_font_size(new_font_size: float) -> void:
font_size = new_font_size
_updated.emit()
func _set_override_font_color(enabled: bool) -> void:
override_font_color = enabled
_updated.emit()
func _set_font_color(new_font_color: Color) -> void:
font_color = new_font_color
_updated.emit()
func _set_is_bold(new_is_bold: bool) -> void:
is_bold = new_is_bold
_updated.emit()
func _set_is_italic(new_is_italic: bool) -> void:
is_italic = new_is_italic
_updated.emit()
func _set_is_underlined(new_is_underlined: bool) -> void:
is_underlined = new_is_underlined
_updated.emit()

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16"
viewBox="0 0 16 16"
width="16"
version="1.1"
id="svg1"
sodipodi:docname="icon.svg"
xml:space="preserve"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="18.141708"
inkscape:cx="-10.3353"
inkscape:cy="8.4611657"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><path
id="path1"
style="fill:#8eef97;fill-opacity:1"
d="M 6,3 C 5.7348055,3.0000566 5.4804613,3.1054195 5.2929688,3.2929688 l -4,4 c -0.3903816,0.3904995 -0.3903816,1.0235629 0,1.4140624 l 4,3.9999998 C 5.4804613,12.89458 5.7348055,12.999943 6,13 h 8 c 0.552284,0 1,-0.447716 1,-1 V 4 C 15,3.4477159 14.552284,3 14,3 Z M 3.7265625,5.6132812 H 5.1621094 L 6.5976562,7.4082031 8.0332031,5.6132812 H 9.46875 V 10.494141 H 8.0332031 V 7.6953125 L 6.5976562,9.4902344 5.1621094,7.6953125 V 10.494141 H 3.7265625 Z m 8.1835935,0 h 1.435547 V 8.0546875 H 14.78125 L 12.628906,10.566406 10.474609,8.0546875 h 1.435547 z"
sodipodi:nodetypes="ccccccsssscccccccccccccccccccccc" /></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cy1fanmsiigs1"
path="res://.godot/imported/icon.svg-159f39e2b062b4de1e0ce4f170ca2380.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/markdownlabel/icon.svg"
dest_files=["res://.godot/imported/icon.svg-159f39e2b062b4de1e0ce4f170ca2380.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@ -0,0 +1,765 @@
@tool
class_name MarkdownLabel
extends RichTextLabel
## A control for displaying Markdown-style text.
##
## A custom node that extends [RichTextLabel] to use Markdown instead of BBCode.
## [br][br]
## [b][u]Usage:[/u][/b]
## Simply add a MarkdownLabel to the scene and write its [member markdown_text] field in Markdown format. Alternatively, you can use the [method display_file] method to automatically import the contents of a Markdown file.
## [br][br]
## On its [RichTextLabel] properties: [member RichTextLabel.bbcode_enabled] property must be enabled. Do not touch the [member RichTextLabel.text] property, since it's used by MarkdownLabel to properly format its text. You can use the rest of its properties as normal.
## [br][br]
## You can still use BBCode tags that don't have a Markdown equivalent, such as `[u]underlined text[/u]`, allowing you to have the full functionality of RichTextLabel with the simplicity and readibility of Markdown.
## [br][br]
## Check out the full guide in the Github repo readme file (linked below). If encountering any unreported bug or unexpected bahaviour, please ensure that your Markdown is written as clean as possible, following best practices.
##
## @tutorial(Github repository): https://github.com/daenvil/MarkdownLabel
const _ESCAPE_PLACEHOLDER := ";$\uFFFD:%s$;"
const _ESCAPEABLE_CHARACTERS := "\\*_~`[]()\"<>#-+.!"
const _ESCAPEABLE_CHARACTERS_REGEX := "[\\\\\\*\\_\\~`\\[\\]\\(\\)\\\"\\<\\>#\\-\\+\\.\\!]"
const _CHECKBOX_KEY := "markdownlabel-checkbox"
#region Public:
## Emitted when the node does not handle a click on a link. Can be used to execute custom functions when a link is clicked. [code]meta[/code] is the link metadata (in a regular link, it would be the URL).
signal unhandled_link_clicked(meta: Variant)
## Emitted when a task list checkbox is clicked. Arguments are:
## the id of the checkbox (used internally),
## the line number it is on (within the original Markdown text),
## a boolean representing whether the checkbox is now checked (true) or unchecked (false),
## and a string containing the text after the checkbox (within the same line).
signal task_checkbox_clicked(id: int, line: int, checked: bool, task_string: String)
## The text to be displayed in Markdown format.
@export_multiline var markdown_text: String : set = _set_markdown_text
## If enabled, links will be automatically handled by this node, without needing to manually connect them. Valid header anchors will make the label scroll to that header's position. Valid URLs and e-mails will be opened according to the user's default settings.
@export var automatic_links := true
## If enabled, unrecognized links will be opened as HTTPS URLs (e.g. "example.com" will be opened as "https://example.com"). If disabled, unrecognized links will be left unhandled (emitting the [code]unhandled_link_clicked[/code] signal). Ignored if [code]automatic_links[/code] is disabled.
@export var assume_https_links := true
@export_group("Header formats")
## Formatting options for level-1 headers
@export var h1 := H1Format.new() : set = _set_h1_format
## Formatting options for level-2 headers
@export var h2 := H2Format.new() : set = _set_h2_format
## Formatting options for level-3 headers
@export var h3 := H3Format.new() : set = _set_h3_format
## Formatting options for level-4 headers
@export var h4 := H4Format.new() : set = _set_h4_format
## Formatting options for level-5 headers
@export var h5 := H5Format.new() : set = _set_h5_format
## Formatting options for level-6 headers
@export var h6 := H6Format.new() : set = _set_h6_format
@export_group("Task lists")
## Whether task list checkboxes are clickable or not.
@export var enable_checkbox_clicks := true :
set(new_value):
enable_checkbox_clicks = new_value
_update()
## String that will be displayed for unchecked task list items. Accepts BBCode and Markdown.
@export var unchecked_item_character := "" :
set(new_value):
unchecked_item_character = new_value
_update()
## String that will be displayed for checked task list items. Accepts BBCode and Markdown.
@export var checked_item_character := "" :
set(new_value):
checked_item_character = new_value
_update()
#endregion
#region Private:
var _converted_text: String
var _indent_level: int
var _escaped_characters_map := {}
var _current_paragraph: int = 0
var _header_anchor_paragraph := {}
var _header_anchor_count := {}
var _within_table := false
var _table_row := -1
var _skip_line_break := false
var _checkbox_id: int = 0
var _current_line: int = 0
var _checkbox_record := {}
var _debug_mode := false
#endregion
#region Built-in methods:
@warning_ignore("shadowed_variable")
func _init(markdown_text: String = "") -> void:
bbcode_enabled = true
self.markdown_text = markdown_text
if automatic_links:
meta_clicked.connect(_on_meta_clicked)
func _ready() -> void:
h1.connect("_updated",_update)
h1.connect("changed",_update)
h2.connect("_updated",_update)
h2.connect("changed",_update)
h3.connect("_updated",_update)
h3.connect("changed",_update)
h4.connect("_updated",_update)
h4.connect("changed",_update)
h5.connect("_updated",_update)
h5.connect("changed",_update)
h6.connect("_updated",_update)
h6.connect("changed",_update)
if Engine.is_editor_hint():
bbcode_enabled = true
#else:
#pass
func _on_meta_clicked(meta: Variant) -> void:
if typeof(meta) != TYPE_STRING:
unhandled_link_clicked.emit(meta)
return
if meta.begins_with("{") and _CHECKBOX_KEY in meta:
var parsed: Dictionary = JSON.parse_string(meta)
if parsed[_CHECKBOX_KEY] and _checkbox_record[int(parsed.id)]:
_on_checkbox_clicked(int(parsed.id), parsed.checked)
if not automatic_links:
unhandled_link_clicked.emit(meta)
return
if meta.begins_with("#") and meta in _header_anchor_paragraph:
self.scroll_to_paragraph(_header_anchor_paragraph[meta])
return
var url_pattern := RegEx.new()
url_pattern.compile("^(ftp|http|https):\\/\\/[^\\s\\\"]+$")
var result := url_pattern.search(meta)
if not result:
url_pattern.compile("^mailto:[^\\s]+@[^\\s]+\\.[^\\s]+$")
result = url_pattern.search(meta)
if result:
OS.shell_open(meta)
return
if assume_https_links:
OS.shell_open("https://" + meta)
else:
unhandled_link_clicked.emit(meta)
func _validate_property(property: Dictionary) -> void:
# Hide these properties in the editor:
if property.name in ["bbcode_enabled", "text"]:
property.usage = PROPERTY_USAGE_NO_EDITOR
#endregion
#region Public methods:
## Reads the specified file and displays it as markdown.
func display_file(file_path: String) -> void:
markdown_text = FileAccess.get_file_as_string(file_path)
#endregion
#region Private methods:
func _update() -> void:
text = _convert_markdown(markdown_text)
queue_redraw()
func _set_markdown_text(new_text: String) -> void:
markdown_text = new_text
_update()
func _set_h1_format(new_format: H1Format) -> void:
h1 = new_format
_update()
func _set_h2_format(new_format: H2Format) -> void:
h2 = new_format
_update()
func _set_h3_format(new_format: H3Format) -> void:
h3 = new_format
_update()
func _set_h4_format(new_format: H4Format) -> void:
h4 = new_format
_update()
func _set_h5_format(new_format: H5Format) -> void:
h5 = new_format
_update()
func _set_h6_format(new_format: H6Format) -> void:
h6 = new_format
_update()
func _convert_markdown(source_text: String = "") -> String:
if not bbcode_enabled:
push_warning("WARNING: MarkdownLabel node will not format Markdown syntax if it doesn't have 'bbcode_enabled=true'")
return source_text
_converted_text = ""
var lines := source_text.split("\n")
_current_line = 0
_indent_level = -1
var indent_spaces := []
var indent_types := []
var within_backtick_block := false
var within_tilde_block := false
var within_code_block := false
var current_code_block_char_count: int
_within_table = false
_table_row = -1
_skip_line_break = false
_checkbox_id = 0
for line: String in lines:
line = line.trim_suffix("\r")
_debug("Parsing line: '%s'" % line)
within_code_block = within_tilde_block or within_backtick_block
if _current_line > 0 and not _skip_line_break:
_converted_text += "\n"
_current_paragraph += 1
_skip_line_break = false
_current_line += 1
line = _preprocess_line(line)
# Handle fenced code blocks:
if not within_tilde_block and _denotes_fenced_code_block(line, "`"):
if within_backtick_block:
if line.strip_edges().length() >= current_code_block_char_count:
_converted_text = _converted_text.trim_suffix("\n")
_current_paragraph -= 1
_converted_text += "[/code]"
within_backtick_block = false
_debug("... closing backtick block")
continue
else:
_converted_text += "[code]"
within_backtick_block = true
current_code_block_char_count = line.strip_edges().length()
_debug("... opening backtick block")
continue
elif not within_backtick_block and _denotes_fenced_code_block(line, "~"):
if within_tilde_block:
if line.strip_edges().length() >= current_code_block_char_count:
_converted_text = _converted_text.trim_suffix("\n")
_current_paragraph -= 1
_converted_text += "[/code]"
within_tilde_block = false
_debug("... closing tilde block")
continue
else:
_converted_text += "[code]"
within_tilde_block = true
current_code_block_char_count = line.strip_edges().length()
_debug("... opening tilde block")
continue
if within_code_block: #ignore any formatting inside code block
_converted_text += _escape_bbcode(line)
continue
var _processed_line := line
# Escape characters:
_processed_line = _process_escaped_characters(_processed_line)
# Process syntax:
_processed_line = _process_table_syntax(_processed_line)
_processed_line = _process_list_syntax(_processed_line, indent_spaces, indent_types)
_processed_line = _process_inline_code_syntax(_processed_line)
_processed_line = _process_image_syntax(_processed_line)
_processed_line = _process_link_syntax(_processed_line)
_processed_line = _process_text_formatting_syntax(_processed_line)
_processed_line = _process_header_syntax(_processed_line)
_processed_line = _process_custom_syntax(_processed_line)
# Re-insert escaped characters:
_processed_line = _reset_escaped_chars(_processed_line)
_converted_text += _processed_line
# end for line loop
# Close any remaining open list:
_debug("... end of text, closing all opened lists")
for i in range(_indent_level, -1, -1):
_converted_text += "[/%s]" % indent_types[i]
# Close any remaining open tables:
_debug("... end of text, closing all opened tables")
if _within_table:
_converted_text += "\n[/table]"
_debug("** ORIGINAL:")
_debug(source_text)
_debug(_converted_text)
return _converted_text
## This method is called before any syntax is processed by the node and does nothing by default. It can be overridden to customize how the node handles each line.
func _preprocess_line(line: String) -> String:
return line
## This method is called after all Markdown syntax is processed by the node and does nothing by default. It can be overridden to handle custom additional syntax.
func _process_custom_syntax(line: String) -> String:
return line
func _process_list_syntax(line: String, indent_spaces: Array, indent_types: Array) -> String:
var processed_line := ""
if line.length() == 0 and _indent_level >= 0:
for i in range(_indent_level, -1, -1):
_converted_text += "[/%s]" % indent_types[_indent_level]
_indent_level -= 1
indent_spaces.pop_back()
indent_types.pop_back()
_converted_text += "\n"
_debug("... empty line, closing all list tags")
return ""
if _indent_level == -1:
if line.length() > 2 and line[0] in "-*+" and line[1] == " ":
_indent_level = 0
indent_spaces.append(0)
indent_types.append("ul")
_converted_text += "[ul]"
processed_line = line.substr(2)
_debug("... opening unordered list at level 0")
processed_line = _process_task_list_item(processed_line)
elif line.length() > 3 and line[0] == "1" and line[1] == "." and line[2] == " ":
_indent_level = 0
indent_spaces.append(0)
indent_types.append("ol")
_converted_text += "[ol]"
processed_line = line.substr(3)
_debug("... opening ordered list at level 0")
else:
processed_line = line
return processed_line
var n_s := 0
for _char in line:
if _char == " " or _char == "\t":
n_s += 1
continue
elif _char in "-*+":
if line.length() > n_s + 2 and line[n_s + 1] == " ":
if n_s == indent_spaces[_indent_level]:
processed_line = line.substr(n_s + 2)
_debug("... adding list element at level %d" % _indent_level)
processed_line = _process_task_list_item(processed_line)
break
elif n_s > indent_spaces[_indent_level]:
_indent_level += 1
indent_spaces.append(n_s)
indent_types.append("ul")
_converted_text += "[ul]"
processed_line = line.substr(n_s + 2)
_debug("... opening list at level %d and adding element" % _indent_level)
processed_line = _process_task_list_item(processed_line)
break
else:
for i in range(_indent_level, -1, -1):
if n_s < indent_spaces[i]:
_converted_text += "[/%s]" % indent_types[_indent_level]
_indent_level -= 1
indent_spaces.pop_back()
indent_types.pop_back()
else:
break
_converted_text += "\n"
processed_line = line.substr(n_s + 2)
_debug("...closing lists down to level %d and adding element" % _indent_level)
processed_line = _process_task_list_item(processed_line)
break
elif _char in "123456789":
if line.length() > n_s + 3 and line[n_s + 1] == "." and line[n_s + 2] == " ":
if n_s == indent_spaces[_indent_level]:
processed_line = line.substr(n_s + 3)
_debug("... adding list element at level %d" % _indent_level)
break
elif n_s > indent_spaces[_indent_level]:
_indent_level += 1
indent_spaces.append(n_s)
indent_types.append("ol")
_converted_text += "[ol]"
processed_line = line.substr(n_s + 3)
_debug("... opening list at level %d and adding element" % _indent_level)
break
else:
for i in range(_indent_level, -1, -1):
if n_s < indent_spaces[i]:
_converted_text += "[/%s]" % indent_types[_indent_level]
_indent_level -= 1
indent_spaces.pop_back()
indent_types.pop_back()
else:
break
_converted_text += "\n"
processed_line = line.substr(n_s + 3)
_debug("... closing lists down to level %d and adding element" % _indent_level)
break
#end for _char loop
if processed_line.is_empty():
for i in range(_indent_level, -1, -1):
_converted_text += "[/%s]" % indent_types[i]
_indent_level -= 1
indent_spaces.pop_back()
indent_types.pop_back()
_converted_text += "\n"
processed_line = line
_debug("... regular line, closing all opened lists")
return processed_line
func _process_task_list_item(item: String) -> String:
if item.length() <= 3 or item[0] != "[" or item[2] != "]" or item[3] != " " or not item[1] in " x":
return item
var processed_item := item.erase(0, 3)
var checkbox: String
var meta := {
_CHECKBOX_KEY: true,
"id": _checkbox_id
}
_checkbox_record[_checkbox_id] = _current_line - 1 # _current_line is actually the next line here
_checkbox_id += 1
if item[1] == " ":
checkbox = unchecked_item_character
meta.checked = false
_debug("... item is an unchecked task item")
elif item[1] == "x":
checkbox = checked_item_character
meta.checked = true
_debug("... item is a checked task item")
if enable_checkbox_clicks:
processed_item = processed_item.insert(0, "[url=%s]%s[/url]" % [JSON.stringify(meta), checkbox])
else:
processed_item = processed_item.insert(0, checkbox)
return processed_item
func _process_inline_code_syntax(line: String) -> String:
var regex := RegEx.create_from_string("(`+)(.+?)\\1")
var processed_line := line
while true:
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
var _end := result.get_end()
var unescaped_content := _reset_escaped_chars(result.get_string(2), true)
unescaped_content = _escape_bbcode(unescaped_content)
unescaped_content = _escape_chars(unescaped_content)
processed_line = processed_line.erase(_start, _end - _start).insert(_start, "[code]%s[/code]" % unescaped_content)
_debug("... in-line code: " + unescaped_content)
return processed_line
func _process_image_syntax(line: String) -> String:
var processed_line := line
var regex := RegEx.new()
while true:
regex.compile("\\!\\[(.*?)\\]\\((.*?)\\)")
var result := regex.search(processed_line)
if not result:
break
var found_proper_match := false
var _start := result.get_start()
var _end := result.get_end()
regex.compile("\\[(.*?)\\]")
var texts := regex.search_all(result.get_string())
for _text in texts:
if result.get_string()[_text.get_end()] != "(":
continue
found_proper_match = true
# Check if link has a title:
regex.compile("\\\"(.*?)\\\"")
var title_result := regex.search(result.get_string(2))
var title: String
var url := result.get_string(2)
if title_result:
title = title_result.get_string(1)
url = url.rstrip(" ").trim_suffix(title_result.get_string()).rstrip(" ")
url = _escape_chars(url)
processed_line = processed_line.erase(_start, _end - _start).insert(_start, "[img]%s[/img]" % url)
if title_result and title:
processed_line = processed_line.insert(_start + 12 + url.length() + _text.get_string(1).length(), "[/hint]").insert(_start, "[hint=%s]" % title)
_debug("... hyperlink: " + result.get_string())
break
if not found_proper_match:
break
return processed_line
func _process_link_syntax(line: String) -> String:
var processed_line := line
var regex := RegEx.new()
while true:
regex.compile("\\[(.*?)\\]\\((.*?)\\)")
var result := regex.search(processed_line)
if not result:
break
var found_proper_match := false
var _start := result.get_start()
var _end := result.get_end()
regex.compile("\\[(.*?)\\]")
var texts := regex.search_all(result.get_string())
for _text in texts:
if result.get_string()[_text.get_end()] != "(":
continue
found_proper_match = true
# Check if link has a title:
regex.compile("\\\"(.*?)\\\"")
var title_result := regex.search(result.get_string(2))
var title: String
var url := result.get_string(2)
if title_result:
title = title_result.get_string(1)
url = url.rstrip(" ").trim_suffix(title_result.get_string()).rstrip(" ")
url = _escape_chars(url)
processed_line = processed_line.erase(
_start + _text.get_start(),
_end - _start - _text.get_start()
).insert(
_start + _text.get_start(),
"[url=%s]%s[/url]" % [url, _text.get_string(1)]
)
if title_result and title:
processed_line = processed_line.insert(
_start + _text.get_start() +12 +url.length() + _text.get_string(1).length(),
"[/hint]"
).insert(_start + _text.get_start(), "[hint=%s]" % title)
_debug("... hyperlink: " + result.get_string())
break
if not found_proper_match:
break
while true:
regex.compile("\\<(.*?)\\>")
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
var _end := result.get_end()
var url := result.get_string(1)
regex.compile("^\\s*?([^\\s]+\\@[^\\s]+\\.[^\\s]+)\\s*?$")
var mail := regex.search(result.get_string(1))
if mail:
url = mail.get_string(1)
url = _escape_chars(url)
if mail:
processed_line = processed_line.erase(_start, _end - _start).insert(_start, "[url=mailto:%s]%s[/url]" % [url, url])
_debug("... mail link: " + result.get_string())
else:
processed_line = processed_line.erase(_start, _end - _start).insert(_start, "[url]%s[/url]" % url)
_debug("... explicit link: " + result.get_string())
return processed_line
func _process_text_formatting_syntax(line: String) -> String:
var processed_line := line
# Bold text
var regex := RegEx.create_from_string("(\\*\\*|\\_\\_)(.+?)\\1")
while true:
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
var _end := result.get_end()
processed_line = processed_line.erase(_start, 2).insert(_start, "[b]")
processed_line = processed_line.erase(_end - 1, 2).insert(_end - 1, "[/b]")
_debug("... bold text: "+result.get_string(2))
# Italic text
while true:
regex.compile("(\\*|_)(.+?)\\1")
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
var _end := result.get_end()
# Sanitize nested bold+italics (Godot-specific, b and i tags must not be intertwined):
var result_string := result.get_string(2)
var open_b := false
var close_b := false
if result_string.begins_with("[b]") and result_string.find("[/b]") == -1:
open_b = true
elif result_string.ends_with("[/b]") and result_string.find("[b]") == -1:
close_b = true
if open_b:
processed_line = processed_line.erase(_start, 4).insert(_start, "[b][i]")
processed_line = processed_line.erase(_end - 2, 1).insert(_end - 2, "[/i]")
elif close_b:
processed_line = processed_line.erase(_start, 1).insert(_start, "[i]")
processed_line = processed_line.erase(_end - 3, 5).insert(_end - 3, "[/i][/b]")
else:
processed_line = processed_line.erase(_start, 1).insert(_start, "[i]")
processed_line = processed_line.erase(_end + 1, 1).insert(_end + 1, "[/i]")
_debug("... italic text: "+result.get_string(2))
# Strike-through text
regex.compile("(\\~\\~)(.+?)\\1")
while true:
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
processed_line = processed_line.erase(_start, 2).insert(_start, "[s]")
var _end := result.get_end()
processed_line = processed_line.erase(_end - 1, 2).insert(_end - 1, "[/s]")
_debug("... strike-through text: " + result.get_string(2))
return processed_line
func _process_header_syntax(line: String) -> String:
var processed_line := line
var regex := RegEx.create_from_string("^#+\\s*[^\\s].*")
while true:
var result := regex.search(processed_line)
if not result:
break
var n := 0
for _char in result.get_string():
if _char != "#" or n == 6:
break
n+=1
var n_spaces := 0
for _char in result.get_string().substr(n):
if _char != " ":
break
n_spaces += 1
var header_format: Resource = _get_header_format(n)
var _start := result.get_start()
var opening_tags := _get_header_tags(header_format)
processed_line = processed_line.erase(_start, n + n_spaces).insert(_start, opening_tags)
var _end := result.get_end()
processed_line = processed_line.insert(_end - (n + n_spaces) + opening_tags.length(), _get_header_tags(header_format, true))
_debug("... header level %d" % n)
_header_anchor_paragraph[_get_header_reference(result.get_string())] = _current_paragraph
return processed_line
func _escape_bbcode(source: String) -> String:
return source.replacen("[",_ESCAPE_PLACEHOLDER).replacen("]","[rb]").replacen(_ESCAPE_PLACEHOLDER,"[lb]")
func _escape_chars(_text: String) -> String:
var escaped_text := _text
for _char: String in _ESCAPEABLE_CHARACTERS:
if not _char in _escaped_characters_map:
_escaped_characters_map[_char] = _escaped_characters_map.size()
escaped_text = escaped_text.replacen(_char, _ESCAPE_PLACEHOLDER % _escaped_characters_map[_char])
return escaped_text
func _reset_escaped_chars(_text: String,code:=false) -> String:
var unescaped_text := _text
for _char in _ESCAPEABLE_CHARACTERS:
if not _char in _escaped_characters_map:
continue
unescaped_text = unescaped_text.replacen(_ESCAPE_PLACEHOLDER%_escaped_characters_map[_char],"\\"+_char if code else _char)
return unescaped_text
func _debug(string: String) -> void:
if not _debug_mode:
return
print(string)
func _denotes_fenced_code_block(line: String, character: String) -> bool:
var stripped_line := line.strip_edges()
var count := stripped_line.count(character)
if count >= 3 and count==stripped_line.length():
return true
else:
return false
func _process_escaped_characters(line: String) -> String:
var regex := RegEx.create_from_string("\\\\" + _ESCAPEABLE_CHARACTERS_REGEX)
var processed_line := line
while true:
var result := regex.search(processed_line)
if not result:
break
var _start := result.get_start()
var _escaped_char := result.get_string()[1]
if not _escaped_char in _escaped_characters_map:
_escaped_characters_map[_escaped_char] = _escaped_characters_map.size()
processed_line = processed_line.erase(_start, 2).insert(_start, _ESCAPE_PLACEHOLDER % _escaped_characters_map[_escaped_char])
return processed_line
func _process_table_syntax(line: String) -> String:
if line.count("|") < 2:
if _within_table:
_debug ("... end of table")
_within_table = false
return "[/table]\n"+line
else:
return line
_debug("... table row: "+line)
_table_row += 1
var split_line := line.trim_prefix("|").trim_suffix("|").split("|")
var processed_line := ""
if not _within_table:
processed_line += "[table=%d]\n" % split_line.size()
_within_table = true
elif _table_row == 1:
# Handle delimiter row
var is_delimiter := true
for cell in split_line:
var stripped_cell := cell.strip_edges()
if stripped_cell.count("-")+stripped_cell.count(":") != stripped_cell.length():
is_delimiter = false
break
if is_delimiter:
_skip_line_break = true
return ""
for cell in split_line:
processed_line += "[cell]%s[/cell]" % cell.strip_edges()
return processed_line
func _get_header_format(level: int) -> Resource:
match level:
1:
return h1
2:
return h2
3:
return h3
4:
return h4
5:
return h5
6:
return h6
push_warning("Invalid header level: "+str(level))
return null
func _get_header_tags(header_format: Resource, closing := false) -> String:
if not header_format:
return ""
var tags: String = ""
if closing:
if header_format.is_underlined:
tags += "[/u]"
if header_format.is_italic:
tags += "[/i]"
if header_format.is_bold:
tags += "[/b]"
if header_format.font_size:
tags += "[/font_size]"
if header_format.override_font_color and header_format.font_color:
tags += "[/color]"
else:
if header_format.override_font_color and header_format.font_color:
tags += "[color=#%s]" % header_format.font_color.to_html()
if header_format.font_size:
tags += "[font_size=%d]" % int(header_format.font_size * self.get_theme_font_size("normal_font_size"))
if header_format.is_bold:
tags += "[b]"
if header_format.is_italic:
tags += "[i]"
if header_format.is_underlined:
tags += "[u]"
return tags
func _get_header_reference(header_string: String) -> String:
var anchor := "#" + header_string.lstrip("#").strip_edges().to_lower().replace(" ","-")
if anchor in _header_anchor_count:
_header_anchor_count[anchor] += 1
anchor += "-" + str(_header_anchor_count[anchor]-1)
else:
_header_anchor_count[anchor] = 1
return anchor
func _on_checkbox_clicked(id: int, was_checked: bool) -> void:
var iline: int = _checkbox_record[id]
var lines := markdown_text.split("\n")
var old_string := "[x]" if was_checked else "[ ]"
var new_string := "[ ]" if was_checked else "[x]"
var i := lines[iline].find(old_string)
if i == -1:
push_error("Couldn't find the clicked task list checkbox (id=%d, line=%d)" % [id, iline]) # Shouldn't happen. Please report the bug if it happens.
return
lines[iline] = lines[iline].erase(i, old_string.length()).insert(i, new_string)
markdown_text = "\n".join(lines)
task_checkbox_clicked.emit(id, iline, !was_checked, lines[iline].substr(i + 4))
#endregion

View File

@ -0,0 +1,7 @@
[plugin]
name="MarkdownLabel"
description="A custom node that extends RichTextLabel to use Markdown instead of BBCode."
author="Daenvil"
version="1.2.0"
script="plugin.gd"

View File

@ -0,0 +1,11 @@
@tool
extends EditorPlugin
func _enter_tree():
# Initialization of the plugin goes here.
# Add the new type with a name, a parent type, a script and an icon.
add_custom_type("MarkdownLabel", "RichTextLabel", preload("markdownlabel.gd"), preload("icon.svg"))
func _exit_tree():
remove_custom_type("MarkdownLabel")