Add bbcode markup support
This commit is contained in:
parent
6584c53906
commit
0489d6c3e5
@ -7,6 +7,7 @@
|
|||||||
- (Base) New `DeckPane` component with swipe and slide transition support.
|
- (Base) New `DeckPane` component with swipe and slide transition support.
|
||||||
- (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input.
|
- (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input.
|
||||||
- (Base) New `PasswordTextField` component to simplify `PasswordTextFormatter` usage.
|
- (Base) New `PasswordTextField` component to simplify `PasswordTextFormatter` usage.
|
||||||
|
- (Base) 🚀 [BBCode](https://ru.wikipedia.org/wiki/BBCode) markup support.
|
||||||
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
|
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
|
||||||
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
|
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
|
||||||
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).
|
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).
|
||||||
|
758
base/src/main/java/atlantafx/base/util/BBCodeHandler.java
Normal file
758
base/src/main/java/atlantafx/base/util/BBCodeHandler.java
Normal file
@ -0,0 +1,758 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import atlantafx.base.theme.Styles;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import javafx.geometry.HPos;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Hyperlink;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.ColumnConstraints;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.RowConstraints;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextAlignment;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic handler interface for the {@link BBCodeParser} that will
|
||||||
|
* receive notifications while processing user input text.
|
||||||
|
*/
|
||||||
|
public interface BBCodeHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies that parsing has started.
|
||||||
|
*
|
||||||
|
* @param doc parser input string
|
||||||
|
*/
|
||||||
|
void startDocument(char[] doc);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies that parsing has finished.
|
||||||
|
*/
|
||||||
|
void endDocument();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies about the start of the tag.
|
||||||
|
* In case of self-closing tag this also notifies about the end of the tag.
|
||||||
|
*
|
||||||
|
* @param name tag name
|
||||||
|
* @param params tag params
|
||||||
|
* @param start tag start position, i.e. the position of open square bracket (not the tag name start)
|
||||||
|
* @param length tag length, including closing bracket
|
||||||
|
*/
|
||||||
|
void startTag(String name, @Nullable Map<String, String> params, int start, int length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies about the end of the tag.
|
||||||
|
* In case of self-closing tag only {@link #startTag(String, Map, int, int)} method is called.
|
||||||
|
*
|
||||||
|
* @param name tag name
|
||||||
|
* @param start tag start position, i.e. the position of open square bracket (not the tag name start)
|
||||||
|
* @param length tag length, including closing bracket
|
||||||
|
*/
|
||||||
|
void endTag(String name, int start, int length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies about characters data that doesn't belong to any tag, i.e.
|
||||||
|
* leading, intermediate or trailing text.
|
||||||
|
*
|
||||||
|
* @param start text start position
|
||||||
|
* @param length text length
|
||||||
|
*/
|
||||||
|
void characters(int start, int length);
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic {@link BBCodeHandler} implementation.<br/><br/>
|
||||||
|
*
|
||||||
|
* <p>While parsing all created nodes will be added to the given root container.
|
||||||
|
* The choice depends on the actual markup. Default constructor accepts any {@link Pane}
|
||||||
|
* or its descendant. Using {@link TextFlow} for text-only markup (no block nodes) and
|
||||||
|
* {@link VBox} otherwise, is recommended.<br/><br/>
|
||||||
|
*
|
||||||
|
* <h3>Supported tags</h3><br/>
|
||||||
|
* <pre>
|
||||||
|
* Bold Text : text : [b]{text}[/b]
|
||||||
|
* Italic Text : text : [i]{text}[/i]
|
||||||
|
* Underlined Text : text : [u]{text}[/u]
|
||||||
|
* Strikethrough Text : text : [s]{text}[/s]
|
||||||
|
* Font Color : text : [color={color}]{text}[/color]
|
||||||
|
* Font Family : text : [font={monospace}]{text}[/font]
|
||||||
|
* Font Size : text : [size={size}]{text}[/size]
|
||||||
|
* Link : text : [url={url}]{text}[/url]
|
||||||
|
* [url url={url} class={class}]{text}[/url]
|
||||||
|
* Email : text : [email]{text}[/email]
|
||||||
|
* [email email={url} class={class}]{text}[/email]
|
||||||
|
* Style : text : [style={style}]{text}[/style]
|
||||||
|
* Subscript : text : [sub]{text}[/sub]
|
||||||
|
* Superscript : text : [sup]{text}[/sup]
|
||||||
|
* Heading : text : [heading]{text}[/heading]
|
||||||
|
* [heading={level}]{text}[/heading]
|
||||||
|
* Code : text : [code]{text}[/code]
|
||||||
|
* [code={class}]{text}[/code]
|
||||||
|
* Span : text : [span]{text}[/span]
|
||||||
|
* [span={class}]{text}[/span]
|
||||||
|
* [span style={style} class={class}]{text}[/span]
|
||||||
|
* Label : text : [label]{text}[/label]
|
||||||
|
* [label={class}]{text}[/label]
|
||||||
|
* [label style={style} class={class}]{text}[/label]
|
||||||
|
* Caption Text : text : [caption]{text}[/caption]
|
||||||
|
* Small Text : text : [small]{text}[/small]
|
||||||
|
* Abbreviation : text : [abbr="tooltip text"]{text}[/abbr]
|
||||||
|
* Unordered List : block : [ul]
|
||||||
|
* [li]Entry 1[/li]
|
||||||
|
* [li]Entry 2[/li]
|
||||||
|
* [/ul]
|
||||||
|
* [ul={bullet character}]
|
||||||
|
* [li]Entry 1[/li]
|
||||||
|
* [li]Entry 2[/li]
|
||||||
|
* [/ul]
|
||||||
|
* Ordered List : block : [ol]
|
||||||
|
* [li]Entry 1[/li]
|
||||||
|
* [li]Entry 2[/li]
|
||||||
|
* [/ol]
|
||||||
|
* [ol={start number or letter}]
|
||||||
|
* [li]Entry 1[/li]
|
||||||
|
* [li]Entry 2[/li]
|
||||||
|
* [/ol]
|
||||||
|
* Alignment : block : [left]{content}[/left]
|
||||||
|
* [center]{content}[/center]
|
||||||
|
* [right]{content}[/right]
|
||||||
|
* [align={javafx.geometry.Pos}]{content}[/align]
|
||||||
|
* Indentation : block : [indent]{content}[/indent]
|
||||||
|
* [indent=level]{content}[/indent]
|
||||||
|
* Horizontal Rule : block : [hr/]
|
||||||
|
* [hr=thickness/]
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>If tag param contains whitespaces or trailing slash is must be
|
||||||
|
* enclosed in double or single quotes.
|
||||||
|
* <li>If tag only has a single param, it can be shortened to {@code [name=value]{text}[/name]}.
|
||||||
|
* In that case tag param name considered to be equal to the tag name.
|
||||||
|
* <li>Unknown tag params will be ignored.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Action Events</h3><br/>
|
||||||
|
* Some nodes, e.g. {@link Hyperlink} require action handlers. To avoid traversing
|
||||||
|
* the root node graph you can add an event filter.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* var input = "Visit the [url=https://example.com]website[/url].";
|
||||||
|
* var textFlow = BBCodeParser.createLayout(input);
|
||||||
|
* textFlow.addEventFilter(ActionEvent.ACTION, e-> {
|
||||||
|
* if (e.getTarget() instanceof Hyperlink link) {
|
||||||
|
* openURL(link.getUserData());
|
||||||
|
* }
|
||||||
|
* e.consume();
|
||||||
|
* });}
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
class Default<T extends Pane> implements BBCodeHandler {
|
||||||
|
|
||||||
|
protected static final int OL_LETTER_OFFSET = 100_000;
|
||||||
|
|
||||||
|
protected final Block root;
|
||||||
|
protected char[] doc;
|
||||||
|
protected Deque<Tag> openTags = new ArrayDeque<>();
|
||||||
|
protected Deque<Block> openBlocks = new ArrayDeque<>();
|
||||||
|
protected int textCursor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new handler instance.
|
||||||
|
*
|
||||||
|
* @param root root container
|
||||||
|
*/
|
||||||
|
public Default(T root) {
|
||||||
|
Objects.requireNonNull(root, "Root container cannot be null.");
|
||||||
|
this.root = root instanceof TextFlow tf ? new Block(root, tf) : new Block(root, new TextFlow());
|
||||||
|
this.root.node().getStyleClass().add("bb-code");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startDocument(char[] doc) {
|
||||||
|
this.doc = doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endDocument() {
|
||||||
|
this.doc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startTag(String name, @Nullable Map<String, String> params, int start, int length) {
|
||||||
|
Tag tag = createTag(name, params);
|
||||||
|
|
||||||
|
// ignore unknown tags
|
||||||
|
if (tag == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// append all text before the current cursor position
|
||||||
|
if (!openTags.isEmpty()) {
|
||||||
|
appendTextToCurrentBranch(openTags.getFirst(), textCursor, start - textCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tag.isSelfClose()) {
|
||||||
|
// push newly opened tag on top
|
||||||
|
openTags.addFirst(tag);
|
||||||
|
|
||||||
|
// if tag is a block or an inline block, update branch reference
|
||||||
|
if (tag.isBlock()) {
|
||||||
|
createBranch();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
appendSelfCloseTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// move text cursor to the first char inside the new tag
|
||||||
|
// (or to the first char after the self-close tag)
|
||||||
|
textCursor = start + length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endTag(String name, int start, int length) {
|
||||||
|
// closing the first tag node from stack
|
||||||
|
Tag tag = openTags.getFirst();
|
||||||
|
|
||||||
|
// append all text before the current cursor position
|
||||||
|
// and move text cursor to the first char after the closing tag
|
||||||
|
appendTextToCurrentBranch(tag, textCursor, start - textCursor);
|
||||||
|
textCursor = start + length;
|
||||||
|
|
||||||
|
openTags.removeFirst(); // close tag
|
||||||
|
if (tag.isBlock()) { // return to the parent node
|
||||||
|
openBlocks.removeFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void characters(int start, int length) {
|
||||||
|
if (length > 0) {
|
||||||
|
var text = new Text(new String(doc, start, length));
|
||||||
|
|
||||||
|
if (root.node() instanceof TextFlow) {
|
||||||
|
// support special use case for simple markup
|
||||||
|
root.children().add(text);
|
||||||
|
} else {
|
||||||
|
// otherwise text is always appended to the root,
|
||||||
|
// this also creates a new TextFlow if necessary
|
||||||
|
appendTextToRoot(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected @Nullable Tag createTag(String name, @Nullable Map<String, String> params) {
|
||||||
|
Tag.Type tagType = null;
|
||||||
|
|
||||||
|
// all styles added here will be inherited by nested tags
|
||||||
|
Set<String> stylesClass = new HashSet<>();
|
||||||
|
Set<String> style = new HashSet<>();
|
||||||
|
|
||||||
|
// all supported tags must be listed here
|
||||||
|
switch (name) {
|
||||||
|
// == TEXT STYLE ==
|
||||||
|
case "b" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_BOLD);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "i" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_ITALIC);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "u" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_UNDERLINED);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "s" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_STRIKETHROUGH);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "color" -> {
|
||||||
|
addStyleIfPresent(params, "-fx-fill", "color", style);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "font" -> {
|
||||||
|
addStyleIfPresent(params, "-fx-font-family", "font", style);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "style" -> {
|
||||||
|
addStyleIfPresent(params, "style", style, ";");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "sub" -> {
|
||||||
|
stylesClass.add("sub");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "sup" -> {
|
||||||
|
stylesClass.add("sup");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// == TEXT SIZE ==
|
||||||
|
case "heading" -> {
|
||||||
|
if (params != null && params.containsKey("heading")) {
|
||||||
|
stylesClass.add("title-" + getParamOrDefault(params, "heading", "3"));
|
||||||
|
}
|
||||||
|
stylesClass.add("heading");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "caption" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_CAPTION);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "size" -> {
|
||||||
|
addStyleIfPresent(params, "-fx-font-size", "size", style);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "small" -> {
|
||||||
|
stylesClass.add(Styles.TEXT_SMALL);
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LINKS
|
||||||
|
case "url", "email" -> {
|
||||||
|
// support style class to differentiate controls in action handler
|
||||||
|
addStyleIfPresent(params, "class", stylesClass, " ");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// == CUSTOM FORMATTING ==
|
||||||
|
case "code" -> {
|
||||||
|
stylesClass.add("code");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "span" -> {
|
||||||
|
addStyleIfPresent(params, "span", stylesClass, " ");
|
||||||
|
addStyleIfPresent(params, "class", stylesClass, " ");
|
||||||
|
addStyleIfPresent(params, "style", style, ";");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
case "label" -> {
|
||||||
|
addStyleIfPresent(params, "label", stylesClass, " ");
|
||||||
|
addStyleIfPresent(params, "class", stylesClass, " ");
|
||||||
|
addStyleIfPresent(params, "style", style, ";");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INTERACTIVE
|
||||||
|
case "abbr" -> {
|
||||||
|
stylesClass.add("abbr");
|
||||||
|
tagType = Tag.Type.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// == BLOCKS ==
|
||||||
|
case "ol", "ul" -> {
|
||||||
|
addStyleIfPresent(params, "class", stylesClass, " ");
|
||||||
|
tagType = Tag.Type.BLOCK;
|
||||||
|
}
|
||||||
|
case "align", "li", "left", "center", "right", "indent" -> tagType = Tag.Type.BLOCK;
|
||||||
|
|
||||||
|
// == SELF-CLOSE ==
|
||||||
|
case "hr" -> tagType = Tag.Type.SELF_CLOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagType != null ? new Tag(name, tagType, params, stylesClass, style) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void appendTextToRoot(Node node) {
|
||||||
|
TextFlow parent;
|
||||||
|
|
||||||
|
// append to the last TextFlow node, create one if not present
|
||||||
|
if (root.isEmpty() || !(root.children().get(root.size() - 1) instanceof TextFlow)) {
|
||||||
|
parent = new TextFlow();
|
||||||
|
root.children().add(parent);
|
||||||
|
} else {
|
||||||
|
parent = (TextFlow) root.children().get(root.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.getChildren().add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void appendTextToCurrentBranch(Tag tag, int textStart, int textLength) {
|
||||||
|
if (textLength > 0) {
|
||||||
|
Node node = createTextNode(tag, new String(doc, textStart, textLength));
|
||||||
|
node.getStyleClass().addAll(getStyleClass()); // inherit all styles from stack
|
||||||
|
node.setStyle(getStyle());
|
||||||
|
|
||||||
|
// if branch is empty, start a new paragraph from root
|
||||||
|
if (openBlocks.isEmpty()) {
|
||||||
|
appendTextToRoot(node);
|
||||||
|
} else {
|
||||||
|
openBlocks.getFirst().addText(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Node createTextNode(Tag tag, String text) {
|
||||||
|
return switch (tag.name()) {
|
||||||
|
case "label", "code" -> new Label(text);
|
||||||
|
case "url" -> {
|
||||||
|
var node = new Hyperlink(text);
|
||||||
|
node.setUserData(tag.getParam("url"));
|
||||||
|
node.getStyleClass().add("url");
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
case "email" -> {
|
||||||
|
var node = new Hyperlink(text);
|
||||||
|
node.setUserData(tag.getParam("email"));
|
||||||
|
node.getStyleClass().add("email");
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
case "abbr" -> {
|
||||||
|
var node = new Label(text);
|
||||||
|
String tooltip = tag.getParam("abbr");
|
||||||
|
if (tooltip != null) {
|
||||||
|
node.setTooltip(new Tooltip(tooltip));
|
||||||
|
}
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
var node = new Text(text);
|
||||||
|
node.getStyleClass().add(Styles.TEXT);
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void createBranch() {
|
||||||
|
final var tag = openTags.getFirst();
|
||||||
|
if (!tag.isBlock()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final var hgap = 10;
|
||||||
|
final var vgap = 10;
|
||||||
|
boolean isGrid = false;
|
||||||
|
|
||||||
|
final Block parent = !openBlocks.isEmpty() ? openBlocks.getFirst() : root;
|
||||||
|
final Block current = switch (tag.name()) {
|
||||||
|
case "ul" -> {
|
||||||
|
var pane = new VBox(vgap);
|
||||||
|
pane.setUserData(tag.getParam("ul", "•"));
|
||||||
|
pane.getStyleClass().addAll("ul", "list");
|
||||||
|
yield new Block(pane, null);
|
||||||
|
}
|
||||||
|
case "ol" -> {
|
||||||
|
var grid = new GridPane();
|
||||||
|
grid.setVgap(vgap);
|
||||||
|
grid.setHgap(hgap);
|
||||||
|
grid.setUserData(getListStartNumber(tag.getParam("ol")));
|
||||||
|
grid.getStyleClass().addAll("ol", "list");
|
||||||
|
grid.getColumnConstraints().addAll(
|
||||||
|
new ColumnConstraints(
|
||||||
|
Region.USE_PREF_SIZE, -1, Region.USE_PREF_SIZE, Priority.NEVER, HPos.LEFT, false
|
||||||
|
),
|
||||||
|
new ColumnConstraints(
|
||||||
|
-1, -1, -1, Priority.SOMETIMES, HPos.LEFT, false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
yield new Block(grid, null);
|
||||||
|
}
|
||||||
|
case "li" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
|
||||||
|
if (parent.node().getStyleClass().contains("ol") && parent.node() instanceof GridPane grid) {
|
||||||
|
isGrid = true;
|
||||||
|
// use Label instead of Text because of better vertical alignment
|
||||||
|
grid.addRow(grid.getRowCount(), new Label(getListItemNumber(grid)), text);
|
||||||
|
grid.getRowConstraints().add(
|
||||||
|
new RowConstraints(
|
||||||
|
-1, -1, -1, Priority.NEVER, VPos.TOP, false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
yield new Block(text, text);
|
||||||
|
} else if (parent.node().getStyleClass().contains("ul")) {
|
||||||
|
var bullet = String.valueOf(parent.node().getUserData());
|
||||||
|
HBox pane = new HBox(hgap, new Text(bullet), text);
|
||||||
|
pane.setAlignment(Pos.BASELINE_LEFT);
|
||||||
|
pane.getStyleClass().add("li");
|
||||||
|
yield new Block(pane, text);
|
||||||
|
} else {
|
||||||
|
throw new UnsupportedOperationException("Invalid parent tag: 'ul' or 'ol' required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "align" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
var pane = new HBox(text);
|
||||||
|
pane.setAlignment(getEnumValue(Pos.class, tag.getParam("align"), Pos.TOP_LEFT));
|
||||||
|
yield new Block(pane, text);
|
||||||
|
}
|
||||||
|
case "left" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
text.setTextAlignment(TextAlignment.LEFT);
|
||||||
|
|
||||||
|
var pane = new HBox(text);
|
||||||
|
pane.setAlignment(Pos.TOP_LEFT);
|
||||||
|
|
||||||
|
yield new Block(pane, text);
|
||||||
|
}
|
||||||
|
case "center" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
text.setTextAlignment(TextAlignment.CENTER);
|
||||||
|
|
||||||
|
var pane = new HBox(text);
|
||||||
|
pane.setAlignment(Pos.TOP_CENTER);
|
||||||
|
|
||||||
|
yield new Block(pane, text);
|
||||||
|
}
|
||||||
|
case "right" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
text.setTextAlignment(TextAlignment.RIGHT);
|
||||||
|
|
||||||
|
var pane = new HBox(text);
|
||||||
|
pane.setAlignment(Pos.TOP_RIGHT);
|
||||||
|
|
||||||
|
yield new Block(pane, text);
|
||||||
|
}
|
||||||
|
case "indent" -> {
|
||||||
|
var text = new TextFlow();
|
||||||
|
var pane = new HBox(text);
|
||||||
|
var indent = parseIntOrDefault(tag.getParam("indent"), 1);
|
||||||
|
pane.setPadding(new Insets(0, 0, 0, indent * 10));
|
||||||
|
pane.getStyleClass().add("indent");
|
||||||
|
yield new Block(pane, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
default -> new Block(new VBox(), null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// inherit all styles from stack
|
||||||
|
current.node().getStyleClass().addAll(getStyleClass());
|
||||||
|
|
||||||
|
var currentStyle = current.node().getStyle();
|
||||||
|
var newStyle = getStyle();
|
||||||
|
current.node().setStyle(
|
||||||
|
currentStyle != null && !currentStyle.isEmpty() ? currentStyle + ";" + newStyle : getStyle()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isGrid) { // grid requires col and row number
|
||||||
|
parent.children().add(current.node());
|
||||||
|
}
|
||||||
|
|
||||||
|
openBlocks.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void appendSelfCloseTag(Tag tag) {
|
||||||
|
final Block parent = !openBlocks.isEmpty() ? openBlocks.getFirst() : root;
|
||||||
|
Node node = null;
|
||||||
|
|
||||||
|
if ("hr".equals(tag.name())) {
|
||||||
|
node = new HBox();
|
||||||
|
node.getStyleClass().add("hr");
|
||||||
|
var thickness = parseIntOrDefault(tag.getParam("hr"), 1);
|
||||||
|
node.setStyle(String.format("-fx-border-width:0 0 %d 0", thickness));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node != null) {
|
||||||
|
parent.children().add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void addStyleIfPresent(Map<String, String> params, String name, String key, Collection<String> c) {
|
||||||
|
if (params != null && params.containsKey(key)) {
|
||||||
|
c.add(name + ":" + params.get(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void addStyleIfPresent(Map<String, String> params, String key, Collection<String> c, String sep) {
|
||||||
|
if (params != null && params.containsKey(key)) {
|
||||||
|
Collections.addAll(c, params.get(key).split(sep));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <E extends Enum<E>> E getEnumValue(Class<E> c, @Nullable String value, E defaultValue) {
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Enum.valueOf(c, value.toUpperCase());
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected int getListStartNumber(@Nullable String param) {
|
||||||
|
int start = 1;
|
||||||
|
|
||||||
|
if (param == null) {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
start = Integer.parseInt(param);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
if (param.length() == 1 && Character.isLetter(param.charAt(0))) {
|
||||||
|
start = OL_LETTER_OFFSET + param.charAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected int parseIntOrDefault(@Nullable String s, int defaultValue) {
|
||||||
|
if (s == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getParamOrDefault(Map<String, String> params, String key, String defaultValue) {
|
||||||
|
if (params != null) {
|
||||||
|
return params.getOrDefault(key, defaultValue);
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getListItemNumber(GridPane grid) {
|
||||||
|
int start = (int) Objects.requireNonNullElse(grid.getUserData(), 1);
|
||||||
|
return start < OL_LETTER_OFFSET
|
||||||
|
? (start + grid.getRowCount()) + "." // number
|
||||||
|
: (char) (start + grid.getRowCount() - OL_LETTER_OFFSET) + "."; // letter
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Set<String> getStyleClass() {
|
||||||
|
// set keys cannot be overwritten,
|
||||||
|
// if parent contains the same style class, child wins
|
||||||
|
Iterator<Tag> it = openTags.descendingIterator();
|
||||||
|
Set<String> styleClass = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
var tag = it.next();
|
||||||
|
styleClass.addAll(tag.styleClasses());
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getStyle() {
|
||||||
|
// set keys cannot be overwritten,
|
||||||
|
// if parent contains the same style rule, child wins
|
||||||
|
Iterator<Tag> it = openTags.descendingIterator();
|
||||||
|
Set<String> style = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
var tag = it.next();
|
||||||
|
style.addAll(tag.styles());
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.join(";", style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic block record.
|
||||||
|
*
|
||||||
|
* @param node the node that represents the block
|
||||||
|
* @param text text content
|
||||||
|
*/
|
||||||
|
record Block(Pane node, @Nullable TextFlow text) {
|
||||||
|
|
||||||
|
public Block {
|
||||||
|
Objects.requireNonNull(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addText(Node child) {
|
||||||
|
// Considering that markup can be generated at runtime the policy
|
||||||
|
// is to silently ignore errors where possible. This can lead to
|
||||||
|
// misaligned blocks or missed text at worst.
|
||||||
|
if (canContainText()) {
|
||||||
|
text.getChildren().add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Node> children() {
|
||||||
|
return node.getChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return node.getChildren().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return node.getChildren().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canContainText() {
|
||||||
|
return text != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tag record.
|
||||||
|
*
|
||||||
|
* @param name tag name
|
||||||
|
* @param params tag params
|
||||||
|
* @param styleClasses CSS classes, each element is either a single style or space delimited string
|
||||||
|
* @param styles CSS styles, each element is either a single style or semicolon delimited string
|
||||||
|
*/
|
||||||
|
record Tag(String name,
|
||||||
|
Type type,
|
||||||
|
@Nullable Map<String, String> params,
|
||||||
|
Set<String> styleClasses,
|
||||||
|
Set<String> styles) {
|
||||||
|
|
||||||
|
public enum Type {
|
||||||
|
BLOCK, TEXT, SELF_CLOSE
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tag {
|
||||||
|
Objects.requireNonNull(name);
|
||||||
|
Objects.requireNonNull(type);
|
||||||
|
params = Objects.requireNonNullElse(params, Collections.emptyMap());
|
||||||
|
styleClasses = Objects.requireNonNullElse(styleClasses, Collections.emptySet());
|
||||||
|
styles = Objects.requireNonNullElse(styles, Collections.emptySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasParam(String name) {
|
||||||
|
return params != null && params.containsKey(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParam(String name) {
|
||||||
|
return params != null ? params.get(name) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParam(String name, String defaultValue) {
|
||||||
|
return params != null ? params.getOrDefault(name, defaultValue) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBlock() {
|
||||||
|
return type == Type.BLOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSelfClose() {
|
||||||
|
return type == Type.SELF_CLOSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
298
base/src/main/java/atlantafx/base/util/BBCodeParser.java
Normal file
298
base/src/main/java/atlantafx/base/util/BBCodeParser.java
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple push parser for the <a href="https://www.bbcode.org/">BBCode</a> markup.
|
||||||
|
* As the content is parsed, methods of {@link BBCodeHandler} are called.<br/><br/>
|
||||||
|
*
|
||||||
|
* <p>The parser doesn't impose restrictions on tag names or tag params.
|
||||||
|
* It's a handler implementation responsibility to differentiate supported
|
||||||
|
* tags from unsupported and so to for tag params. This allows user to utilize
|
||||||
|
* arbitrary tags or params without changing the parser behaviour. The parser,
|
||||||
|
* however, verifies that each opening tag has the matching closing tag.
|
||||||
|
* If parsing is failed due to invalid input an {@link IllegalStateException}
|
||||||
|
* will be thrown.
|
||||||
|
*/
|
||||||
|
public class BBCodeParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved tag names. Note, this isn't the list of supported codes
|
||||||
|
* as the {@link BBCodeHandler} is responsible for implementation.
|
||||||
|
*/
|
||||||
|
public static final Set<String> RESERVED_TAGS = Set.of(
|
||||||
|
// supported by the default handler
|
||||||
|
"abbr", "align", "b", "caption", "center", "code", "color", "email", "heading",
|
||||||
|
"font", "hr", "i", "indent", "label", "left", "li", "ol",
|
||||||
|
"right", "s", "size", "small", "span", "style", "sub", "sup", "u", "ul", "url",
|
||||||
|
// reserved
|
||||||
|
"alert", "em", "fieldset", "h1", "h2", "h3", "h4", "icon", "img", "info", "kbd",
|
||||||
|
"list", "media", "plain", "pre", "quote", "spoiler", "stop", "table", "tooltip",
|
||||||
|
"td", "th", "tr", "warning"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final String input;
|
||||||
|
private final BBCodeHandler handler;
|
||||||
|
private final Set<String> processedTags;
|
||||||
|
private final Deque<String> openTags = new ArrayDeque<>();
|
||||||
|
private int offset = 0;
|
||||||
|
private int lastClosingPos = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See {@link #BBCodeParser(String, BBCodeHandler, Set)}.
|
||||||
|
*/
|
||||||
|
public BBCodeParser(String input, BBCodeHandler handler) {
|
||||||
|
this(input, handler, RESERVED_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new parser.
|
||||||
|
*
|
||||||
|
* @param input an input non-null string
|
||||||
|
* @param handler a {@link BBCodeHandler} implementation
|
||||||
|
* @param tags the list of processed tags, i.e. the tags that parser won't ignore
|
||||||
|
*/
|
||||||
|
public BBCodeParser(String input, BBCodeHandler handler, @Nullable Set<String> tags) {
|
||||||
|
this.input = Objects.requireNonNull(input, "Input can't be null.");
|
||||||
|
this.handler = Objects.requireNonNull(handler, "Handler can't be null.");
|
||||||
|
this.processedTags = Objects.requireNonNullElse(tags, RESERVED_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts input parsing. There's no way to stop the process until
|
||||||
|
* parsing is finished.
|
||||||
|
*/
|
||||||
|
public void parse() {
|
||||||
|
handler.startDocument(input.toCharArray());
|
||||||
|
|
||||||
|
while (offset < input.length()) {
|
||||||
|
if (input.charAt(offset) == '[') {
|
||||||
|
int openBracketPos = offset;
|
||||||
|
int closeBracketPos = input.indexOf(']', offset);
|
||||||
|
|
||||||
|
// a single square bracket, isn't a part of the markup
|
||||||
|
if (closeBracketPos == -1) {
|
||||||
|
offset++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tagLength = closeBracketPos - openBracketPos + 1;
|
||||||
|
|
||||||
|
// empty brackets, isn't a part of the markup
|
||||||
|
if (tagLength == 2) {
|
||||||
|
offset++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.charAt(openBracketPos + 1) != '/') {
|
||||||
|
// push leading and intermediate characters
|
||||||
|
if (openTags.isEmpty()) {
|
||||||
|
handleCharacters(
|
||||||
|
lastClosingPos > 0 ? lastClosingPos + 1 : 0,
|
||||||
|
lastClosingPos > 0 ? offset - lastClosingPos - 1 : offset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean selfClose = input.charAt(closeBracketPos - 1) == '/';
|
||||||
|
var isKnownTag = handleStartTag(openBracketPos, tagLength, selfClose);
|
||||||
|
|
||||||
|
// an unknown "opened tag", and we are not inside opened known tag
|
||||||
|
if (!isKnownTag && openTags.isEmpty()) {
|
||||||
|
handleCharacters(openBracketPos, tagLength);
|
||||||
|
offset += tagLength;
|
||||||
|
lastClosingPos = closeBracketPos + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// move input cursor immediately, because self-close tags can't contain text
|
||||||
|
if (selfClose) {
|
||||||
|
lastClosingPos = closeBracketPos;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var isKnownTag = handleEndTag(openBracketPos, tagLength);
|
||||||
|
|
||||||
|
// an unknown "closing tag", and we are not inside opened known tag
|
||||||
|
if (!isKnownTag && openTags.isEmpty()) {
|
||||||
|
handleCharacters(lastClosingPos, closeBracketPos - lastClosingPos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastClosingPos = closeBracketPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = closeBracketPos + 1;
|
||||||
|
} else {
|
||||||
|
// increment offset for any other character
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openTags.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Invalid BBCode: Opening tags without closing tags: " + openTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// push trailing characters
|
||||||
|
if (lastClosingPos < input.length()) {
|
||||||
|
handleCharacters(
|
||||||
|
lastClosingPos > 0 ? lastClosingPos + 1 : lastClosingPos,
|
||||||
|
lastClosingPos > 0 ? input.length() - lastClosingPos - 1 : input.length()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.endDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See {@link #createLayout(String, Pane)}.
|
||||||
|
*/
|
||||||
|
public static TextFlow createFormattedText(String input) {
|
||||||
|
return createLayout(input, new TextFlow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See {@link #createLayout(String, Pane)}.
|
||||||
|
*/
|
||||||
|
public static VBox createLayout(String input) {
|
||||||
|
var b = new VBox(10);
|
||||||
|
b.setAlignment(Pos.TOP_LEFT);
|
||||||
|
return createLayout(input, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the given string using BBCode markup and returns corresponding layout.
|
||||||
|
* This is a shorthand method for using the feature.
|
||||||
|
*
|
||||||
|
* @param input BBCode markup string
|
||||||
|
* @param container root container
|
||||||
|
*/
|
||||||
|
public static <T extends Pane> T createLayout(String input, T container) {
|
||||||
|
var handler = new BBCodeHandler.Default<>(container);
|
||||||
|
|
||||||
|
var parser = new BBCodeParser(input, handler);
|
||||||
|
parser.parse();
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
protected boolean handleStartTag(int start, int length, boolean selfClose) {
|
||||||
|
List<String> tokens = splitBySpace(input, start + 1, !selfClose ? length - 1 : length - 2);
|
||||||
|
|
||||||
|
// only the name and param names are case-insensitive, param values aren't
|
||||||
|
String name = tokens.get(0).toLowerCase();
|
||||||
|
|
||||||
|
Map<String, String> params = null;
|
||||||
|
for (int i = 0; i < tokens.size(); i++) {
|
||||||
|
var token = tokens.get(i);
|
||||||
|
int separatorPos = token.indexOf("=");
|
||||||
|
|
||||||
|
if (separatorPos >= 0) {
|
||||||
|
String key = token.substring(0, separatorPos).toLowerCase();
|
||||||
|
String value = token.substring(separatorPos + 1);
|
||||||
|
|
||||||
|
if (params == null) {
|
||||||
|
params = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// some bb codes use the format "[name=value]text[/name]",
|
||||||
|
// in that case params map should have just a single key
|
||||||
|
// which is exactly the same as the tag name
|
||||||
|
if (i == 0) {
|
||||||
|
name = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!processedTags.contains(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.startTag(name, params, start, length);
|
||||||
|
|
||||||
|
if (!selfClose) {
|
||||||
|
openTags.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean handleEndTag(int start, int length) {
|
||||||
|
// ignore case
|
||||||
|
String name = input.substring(start + 2, start + length - 1).toLowerCase();
|
||||||
|
|
||||||
|
if (!processedTags.contains(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openTags.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Invalid BBCode: Closing tag without corresponding opening tag: '" + name + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String lastTag = openTags.pop();
|
||||||
|
if (!lastTag.equals(name)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Invalid BBCode: Closing tag '" + name + "' does not match opening tag '" + lastTag + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.endTag(name, start, length);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void handleCharacters(int start, int length) {
|
||||||
|
handler.characters(start, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits input string by whitespace ignoring quoted text. E.g.
|
||||||
|
* <pre>
|
||||||
|
* "foo bar" = ["foo", "bar"]
|
||||||
|
* "foo 'bar baz'" = ["foo", "bar baz"]
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
protected List<String> splitBySpace(String str, int start, int length) {
|
||||||
|
var tokens = new ArrayList<String>();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
boolean insideQuotes = false;
|
||||||
|
|
||||||
|
for (int i = start; i < start + length - 1; i++) {
|
||||||
|
char ch = str.charAt(i);
|
||||||
|
|
||||||
|
if (ch == ' ' && !insideQuotes) {
|
||||||
|
tokens.add(sb.toString());
|
||||||
|
sb = new StringBuilder();
|
||||||
|
} else {
|
||||||
|
if (ch == '"' || ch == '\'') {
|
||||||
|
insideQuotes = !insideQuotes;
|
||||||
|
} else {
|
||||||
|
// remove quotes from param value,
|
||||||
|
// works for [name="value"] format as well
|
||||||
|
sb.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.add(sb.toString());
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
325
base/src/test/java/atlantafx/base/util/BBCodeParserTest.java
Normal file
325
base/src/test/java/atlantafx/base/util/BBCodeParserTest.java
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class BBCodeParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEmptyString() {
|
||||||
|
var handler = BBCodeMockHandler.testString("");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSingleBlankString() {
|
||||||
|
var handler = BBCodeMockHandler.testString(" ");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(handler.text()).containsExactlyInAnyOrder(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoMarkupString() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_is_a_bold_text");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(handler.text()).containsExactlyInAnyOrder("This_is_a_bold_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEmptyTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b][/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, ""));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUnknownTags() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_is_[foo]a_bold[/bar]_text");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(String.join("", handler.text())).isEqualTo("This_is_[foo]a_bold[/bar]_text");
|
||||||
|
|
||||||
|
handler = BBCodeMockHandler.testString("[foo]This_is_a_bold_text[/bar]");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(String.join("", handler.text())).isEqualTo("[foo]This_is_a_bold_text[/bar]");
|
||||||
|
|
||||||
|
handler = BBCodeMockHandler.testString("[/foo]This_is_a_bold_text[bar]");
|
||||||
|
assertThat(handler.tags()).isEmpty();
|
||||||
|
assertThat(String.join("", handler.text())).isEqualTo("[/foo]This_is_a_bold_text[bar]");
|
||||||
|
|
||||||
|
handler = BBCodeMockHandler.testString("[/foo]This_is_a_[b]bold[/b]_text[bar]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "bold"));
|
||||||
|
assertThat(String.join("", handler.text())).isEqualTo("[/foo]This_is_a__text[bar]");
|
||||||
|
|
||||||
|
handler = BBCodeMockHandler.testString("[b]This_is_a_[foo]bold[/bar]_text[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("b", null, "This_is_a_[foo]bold[/bar]_text")
|
||||||
|
);
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSingleTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b]text[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "text"));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLeadingTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b]This[/b]_is_a_bold_text");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "This"));
|
||||||
|
assertThat(handler.text()).containsExactly("_is_a_bold_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testIntermediateTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_is_a_[b]bold[/b]_text");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "bold"));
|
||||||
|
assertThat(handler.text()).containsExactly("This_is_a_", "_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTrailingTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_is_a_bold_[b]text[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "text"));
|
||||||
|
assertThat(handler.text()).containsExactly("This_is_a_bold_");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testShorthandParamSyntax() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b=foo]bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("b", "foo"), "bold"));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testShorthandParamSyntaxWithQuotes() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b=\"foo bar\"]bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("b", "foo bar"), "bold"));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSingleTagParam() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b param=foo]bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("param", "foo"), "bold"));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSingleTagParamWithQuotes() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b param='foo bar']bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("param", "foo bar"), "bold"));
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMultipleTagParams() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b param1=foo param2=bar param3=baz]bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactlyInAnyOrder(new MockTag(
|
||||||
|
"b", Map.of("param1", "foo", "param2", "bar", "param3", "baz"), "bold")
|
||||||
|
);
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMultipleTagParamsWithQuotes() {
|
||||||
|
var handler = BBCodeMockHandler.testString("[b param1=foo param2=\"a b\" param3='c d']bold[/b]");
|
||||||
|
assertThat(handler.tags()).containsExactlyInAnyOrder(new MockTag(
|
||||||
|
"b", Map.of("param1", "foo", "param2", "a b", "param3", "c d"), "bold")
|
||||||
|
);
|
||||||
|
assertThat(handler.text()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSiblingTags() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_[i]is[/i]_a_[b]bold[/b]_[s]text[/s]");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("i", null, "is"),
|
||||||
|
new MockTag("b", null, "bold"),
|
||||||
|
new MockTag("s", null, "text")
|
||||||
|
);
|
||||||
|
assertThat(handler.text()).containsExactly("This_", "_a_", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNestedTags() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_[i][s]is_[b]a[/b]_bold[/s]_text[/i]");
|
||||||
|
assertThat(handler.text()).containsExactly("This_");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("b", null, "a"),
|
||||||
|
new MockTag("s", null, "is_[b]a[/b]_bold"),
|
||||||
|
new MockTag("i", null, "[s]is_[b]a[/b]_bold[/s]_text")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEqualNestedTags() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_[b]is_[b]a[/b]_bold[/b]_text");
|
||||||
|
assertThat(handler.text()).containsExactly("This_", "_text");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("b", null, "a"),
|
||||||
|
new MockTag("b", null, "is_[b]a[/b]_bold")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNestedTagsWithParams() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_[i=foo][s]is_[b bar=baz]a[/b]_bold[/s]_text[/i]");
|
||||||
|
assertThat(handler.text()).containsExactly("This_");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("b", Map.of("bar", "baz"), "a"),
|
||||||
|
new MockTag("s", null, "is_[b bar=baz]a[/b]_bold"),
|
||||||
|
new MockTag("i", Map.of("i", "foo"), "[s]is_[b bar=baz]a[/b]_bold[/s]_text")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUnclosedTagThrowsException() {
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_bold_text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNestedUnclosedTagThrowsException() {
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_[i]bold_[/b]text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUnopenedTagThrowsException() {
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[/b]a_bold_text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNestedUnopenedTagThrowsException() {
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_[/i]bold_[/b]text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNotMatchingClosingTagThrowsException() {
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_bold_[/i]text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTagInsideDoubleBracesThrowsException() {
|
||||||
|
// this is known and won't be fixed
|
||||||
|
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[[url=link]a_bold[/url]]_text"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSelfCloseTag() {
|
||||||
|
var handler = BBCodeMockHandler.testString("This_[hr/]is_a_[hr=5/]bold_text");
|
||||||
|
assertThat(handler.tags()).containsExactly(
|
||||||
|
new MockTag("hr", null, null),
|
||||||
|
new MockTag("hr", Map.of("hr", "5"), null)
|
||||||
|
);
|
||||||
|
assertThat(handler.text()).containsExactlyInAnyOrder("This_", "is_a_", "bold_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
public record MockTag(String name,
|
||||||
|
@Nullable Map<String, String> params,
|
||||||
|
@Nullable String text) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BBCodeMockHandler implements BBCodeHandler {
|
||||||
|
|
||||||
|
private static final Set<String> SELF_CLOSE_TAGS = Set.of("hr", "img");
|
||||||
|
private static final boolean DEBUG = false;
|
||||||
|
private static final Map<String, String> PARAMS_PLACEHOLDER = new HashMap<>();
|
||||||
|
|
||||||
|
private final List<MockTag> tags = new ArrayList<>();
|
||||||
|
private final List<String> text = new ArrayList<>();
|
||||||
|
private final Deque<Integer> textStart = new ArrayDeque<>();
|
||||||
|
private final Deque<Map<String, String>> tagParams = new ArrayDeque<>();
|
||||||
|
private char[] doc;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startDocument(char[] doc) {
|
||||||
|
this.doc = doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endDocument() {
|
||||||
|
this.doc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startTag(String name, @Nullable Map<String, String> params, int start, int length) {
|
||||||
|
debug("START:" + name + "|" + params + "|" + start + "|" + length + "|" + new String(doc, start, length));
|
||||||
|
|
||||||
|
if (!SELF_CLOSE_TAGS.contains(name)) {
|
||||||
|
tagParams.push(params != null ? params : PARAMS_PLACEHOLDER);
|
||||||
|
textStart.push(start + length);
|
||||||
|
} else {
|
||||||
|
tags.add(new MockTag(name, params, null));
|
||||||
|
textStart.push(start + length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endTag(String name, int start, int length) {
|
||||||
|
debug("END:" + name + "|" + start + "|" + length + "|" + new String(doc, start, length));
|
||||||
|
var params = tagParams.pop();
|
||||||
|
var openTagTextStart = textStart.pop();
|
||||||
|
tags.add(new MockTag(
|
||||||
|
name,
|
||||||
|
params != PARAMS_PLACEHOLDER ? params : null,
|
||||||
|
new String(doc, openTagTextStart, start - openTagTextStart))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void characters(int start, int length) {
|
||||||
|
debug("CHARACTERS:" + new String(doc, start, length));
|
||||||
|
if (length > 0) {
|
||||||
|
text.add(new String(doc, start, length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void debug(String s) {
|
||||||
|
if (DEBUG) {
|
||||||
|
System.out.println(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MockTag> tags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> text() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable MockTag get(String name) {
|
||||||
|
return tags.stream()
|
||||||
|
.filter(tag -> name.equals(tag.name()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BBCodeMockHandler testString(String input) {
|
||||||
|
var handler = new BBCodeMockHandler();
|
||||||
|
var parser = new BBCodeParser(input, handler);
|
||||||
|
parser.parse();
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,7 @@ import static atlantafx.sampler.layout.MainModel.SubLayer.SOURCE_CODE;
|
|||||||
|
|
||||||
import atlantafx.sampler.page.Page;
|
import atlantafx.sampler.page.Page;
|
||||||
import atlantafx.sampler.page.components.AccordionPage;
|
import atlantafx.sampler.page.components.AccordionPage;
|
||||||
|
import atlantafx.sampler.page.components.BBCodePage;
|
||||||
import atlantafx.sampler.page.components.BreadcrumbsPage;
|
import atlantafx.sampler.page.components.BreadcrumbsPage;
|
||||||
import atlantafx.sampler.page.components.ButtonPage;
|
import atlantafx.sampler.page.components.ButtonPage;
|
||||||
import atlantafx.sampler.page.components.ChartPage;
|
import atlantafx.sampler.page.components.ChartPage;
|
||||||
@ -186,6 +187,7 @@ public class MainModel {
|
|||||||
var extras = NavTree.Item.group("Extras", new FontIcon(Material2OutlinedMZ.TOGGLE_ON));
|
var extras = NavTree.Item.group("Extras", new FontIcon(Material2OutlinedMZ.TOGGLE_ON));
|
||||||
extras.getChildren().setAll(
|
extras.getChildren().setAll(
|
||||||
NAV_TREE.get(InputGroupPage.class),
|
NAV_TREE.get(InputGroupPage.class),
|
||||||
|
NAV_TREE.get(BBCodePage.class),
|
||||||
NAV_TREE.get(BreadcrumbsPage.class),
|
NAV_TREE.get(BreadcrumbsPage.class),
|
||||||
NAV_TREE.get(CustomTextFieldPage.class),
|
NAV_TREE.get(CustomTextFieldPage.class),
|
||||||
NAV_TREE.get(PopoverPage.class),
|
NAV_TREE.get(PopoverPage.class),
|
||||||
@ -221,6 +223,7 @@ public class MainModel {
|
|||||||
map.put(AccordionPage.class, NavTree.Item.page(AccordionPage.NAME, AccordionPage.class));
|
map.put(AccordionPage.class, NavTree.Item.page(AccordionPage.NAME, AccordionPage.class));
|
||||||
map.put(BreadcrumbsPage.class, NavTree.Item.page(BreadcrumbsPage.NAME, BreadcrumbsPage.class));
|
map.put(BreadcrumbsPage.class, NavTree.Item.page(BreadcrumbsPage.NAME, BreadcrumbsPage.class));
|
||||||
map.put(ButtonPage.class, NavTree.Item.page(ButtonPage.NAME, ButtonPage.class));
|
map.put(ButtonPage.class, NavTree.Item.page(ButtonPage.NAME, ButtonPage.class));
|
||||||
|
map.put(BBCodePage.class, NavTree.Item.page(BBCodePage.NAME, BBCodePage.class));
|
||||||
map.put(ChartPage.class, NavTree.Item.page(ChartPage.NAME, ChartPage.class));
|
map.put(ChartPage.class, NavTree.Item.page(ChartPage.NAME, ChartPage.class));
|
||||||
map.put(CheckBoxPage.class, NavTree.Item.page(CheckBoxPage.NAME, CheckBoxPage.class));
|
map.put(CheckBoxPage.class, NavTree.Item.page(CheckBoxPage.NAME, CheckBoxPage.class));
|
||||||
map.put(ColorPickerPage.class, NavTree.Item.page(ColorPickerPage.NAME, ColorPickerPage.class));
|
map.put(ColorPickerPage.class, NavTree.Item.page(ColorPickerPage.NAME, ColorPickerPage.class));
|
||||||
@ -267,9 +270,9 @@ public class MainModel {
|
|||||||
map.put(TreeTablePage.class, NavTree.Item.page(TreeTablePage.NAME, TreeTablePage.class));
|
map.put(TreeTablePage.class, NavTree.Item.page(TreeTablePage.NAME, TreeTablePage.class));
|
||||||
|
|
||||||
map.put(FileManagerPage.class, NavTree.Item.page(FileManagerPage.NAME, FileManagerPage.class));
|
map.put(FileManagerPage.class, NavTree.Item.page(FileManagerPage.NAME, FileManagerPage.class));
|
||||||
map.put(MusicPlayerPage.class, NavTree.Item.page(FileManagerPage.NAME, MusicPlayerPage.class));
|
map.put(MusicPlayerPage.class, NavTree.Item.page(MusicPlayerPage.NAME, MusicPlayerPage.class));
|
||||||
map.put(WidgetCollectionPage.class, NavTree.Item.page(
|
map.put(WidgetCollectionPage.class, NavTree.Item.page(
|
||||||
FileManagerPage.NAME,
|
WidgetCollectionPage.NAME,
|
||||||
WidgetCollectionPage.class, "Card", "Message", "Stepper", "Tag")
|
WidgetCollectionPage.class, "Card", "Message", "Stepper", "Tag")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -0,0 +1,276 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.sampler.page.components;
|
||||||
|
|
||||||
|
import atlantafx.base.theme.Styles;
|
||||||
|
import atlantafx.base.util.BBCodeParser;
|
||||||
|
import atlantafx.sampler.page.AbstractPage;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
|
public class BBCodePage extends AbstractPage {
|
||||||
|
|
||||||
|
public static final String NAME = "BBCode Markup";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BBCodePage() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
setUserContent(new VBox(20,
|
||||||
|
reference(),
|
||||||
|
article()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private VBox reference() {
|
||||||
|
var root = new VBox(20);
|
||||||
|
root.setAlignment(Pos.TOP_LEFT);
|
||||||
|
|
||||||
|
var header = BBCodeParser.createFormattedText("""
|
||||||
|
[left][heading=1]Reference[/heading][/left]\
|
||||||
|
|
||||||
|
BBCode ("Bulletin Board Code") is a lightweight markup language used to format messages \
|
||||||
|
in many Internet forum software. It was first introduced in 1998. The available tags of BBCode \
|
||||||
|
are usually indicated by square brackets surrounding a keyword, and are parsed before \
|
||||||
|
being translated into [s]HTML[/s] JavaFX layout.""");
|
||||||
|
|
||||||
|
root.getChildren().add(header);
|
||||||
|
|
||||||
|
ReferenceBlock block = new ReferenceBlock(
|
||||||
|
"Bold, italics, underline and strikethrough",
|
||||||
|
"Makes the wrapped text bold, italic, underlined, or strikethrough."
|
||||||
|
);
|
||||||
|
block.addFormattedText("This is [b]bold[/b] text.");
|
||||||
|
block.addFormattedText("This is [i]italic[/i] text.");
|
||||||
|
block.addFormattedText("This is [u]underlined[/u] text.");
|
||||||
|
block.addFormattedText("This is [s]strikethrough[/s] text.");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Text color, font and size",
|
||||||
|
"Changes the color, font, or size of the wrapped text."
|
||||||
|
);
|
||||||
|
block.addFormattedText("This is [color=red]red[/color] text.");
|
||||||
|
block.addFormattedText("This is [color=-color-accent-emphasis]accent[/color] text.");
|
||||||
|
block.addFormattedText("This is [label style='-fx-background-color: yellow']background[/label] color.");
|
||||||
|
block.addFormattedText("This is [font=monospace]monospaced[/font] font.");
|
||||||
|
block.addFormattedText("This is [small]small[/small] and [size=1.5em]big[/size] text.");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Subscript and superscript",
|
||||||
|
"A text that is set slightly below or above the normal line of type, respectively."
|
||||||
|
);
|
||||||
|
block.addLayout("log[sub][small]2[/small][/sub](256) = 8");
|
||||||
|
block.addLayout("10[sup][small]2[/small][/sup] = 100");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Headings levels 1 to 5",
|
||||||
|
"Marks text as a structured heading."
|
||||||
|
);
|
||||||
|
block.addFormattedText("""
|
||||||
|
[heading=1]H1 headline[/heading]
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
|
||||||
|
block.addFormattedText("""
|
||||||
|
[heading=2]H2 headline[/heading]
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
|
||||||
|
block.addFormattedText("""
|
||||||
|
[heading=3]H3 headline[/heading]
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
|
||||||
|
block.addFormattedText("""
|
||||||
|
[heading=4]H4 headline[/heading]
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
|
||||||
|
block.addFormattedText("""
|
||||||
|
[caption]Caption[/caption]
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Lists",
|
||||||
|
"Displays a bulleted or numbered list."
|
||||||
|
);
|
||||||
|
block.addLayout("""
|
||||||
|
[ul]
|
||||||
|
[li]Entry 1[/li]
|
||||||
|
[li]Entry 2[/li]
|
||||||
|
[/ul]""");
|
||||||
|
block.addLayout("""
|
||||||
|
[ul=🗹]
|
||||||
|
[li]Entry 1[/li]
|
||||||
|
[li]Entry 2[/li]
|
||||||
|
[/ul]""");
|
||||||
|
block.addLayout("""
|
||||||
|
[ol]
|
||||||
|
[li]Entry 1[/li]
|
||||||
|
[li]Entry 2[/li]
|
||||||
|
[/ol]""");
|
||||||
|
block.addLayout("""
|
||||||
|
[ol=10]
|
||||||
|
[li]Entry 1[/li]
|
||||||
|
[li]Entry 2[/li]
|
||||||
|
[/ol]""");
|
||||||
|
block.addLayout("""
|
||||||
|
[ol=a]
|
||||||
|
[li]Entry 1[/li]
|
||||||
|
[li]Entry 2[/li]
|
||||||
|
[/ol]""");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Linking",
|
||||||
|
"Links the wrapped text to the specified web page or email address."
|
||||||
|
);
|
||||||
|
block.addFormattedText("[url=https://www.example.com]Go to example.com[/url]");
|
||||||
|
block.addFormattedText("[email=johndoe@example.com]Email me[/email]");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Alignment",
|
||||||
|
"Changes the alignment of the wrapped text."
|
||||||
|
);
|
||||||
|
block.addLayout("[left]Left-aligned[/left]");
|
||||||
|
block.addLayout("[center]Center-aligned[/center]");
|
||||||
|
block.addLayout("[right]Right-aligned[/right]");
|
||||||
|
block.addLayout("[align=center]Center-aligned[/align]");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Text indent",
|
||||||
|
"Indents the wrapped text."
|
||||||
|
);
|
||||||
|
block.addLayout("[indent]Indented text[/indent]");
|
||||||
|
block.addLayout("[indent=3]More indented[/indent]");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Horizontal line",
|
||||||
|
"A horizontal separator line."
|
||||||
|
);
|
||||||
|
block.addLayout("Default line: [hr/]");
|
||||||
|
block.addLayout("Thick line: [hr=5/]");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
block = new ReferenceBlock(
|
||||||
|
"Abbreviation",
|
||||||
|
"An abbreviation, with mouse-over expansion."
|
||||||
|
);
|
||||||
|
block.addLayout("[abbr='on hover text']text[/abbr]");
|
||||||
|
block.addLayout("[abbr]text[/abbr]");
|
||||||
|
root.getChildren().add(block);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VBox article() {
|
||||||
|
var article = """
|
||||||
|
[left][heading=1]Example[/heading][/left]\
|
||||||
|
|
||||||
|
[b]JavaFX[/b] is a Java library used to build Rich Internet Applications. \
|
||||||
|
The applications written using this library can run consistently across multiple \
|
||||||
|
platforms. The applications developed using JavaFX can run on various devices \
|
||||||
|
such as Desktop Computers, Mobile Phones, TVs, Tablets, etc.
|
||||||
|
|
||||||
|
To develop GUI Applications using [color=-color-accent-emphasis]Java programming language[/color], \
|
||||||
|
the programmers rely on libraries such as [s]Advanced Windowing Toolkit[/s] and Swing. \
|
||||||
|
After the advent of JavaFX, these Java programmers can now develop \
|
||||||
|
[abbr='Graphical User Interface']GUI[/abbr] applications effectively with rich content.
|
||||||
|
|
||||||
|
[heading=3]Key Features[/heading]
|
||||||
|
|
||||||
|
Following are some of the [i]important features[/i] of JavaFX:\
|
||||||
|
[ul]
|
||||||
|
[li][b]Written in Java[/b] − The JavaFX library is written in Java and is [u]available for \
|
||||||
|
the languages that can be executed on a JVM[/u], which include − Java, \
|
||||||
|
[url="https://groovy-lang.org/"]Groovy[/url] and JRuby.These JavaFX applications are also \
|
||||||
|
platform independent.[/li]\
|
||||||
|
[li][b]FXML[/b] − JavaFX features a language known as FXML, which is a HTML like declarative \
|
||||||
|
markup language. The sole purpose of this language is to define a user interface.[/li]\
|
||||||
|
[li][b]Scene Builder[/b] − JavaFX provides an application named Scene Builder. On integrating \
|
||||||
|
this application in IDE’s such [as Eclipse and NetBeans], the users can access a drag \
|
||||||
|
and drop design interface, which is used to develop [code]FXML[/code] applications (just like \
|
||||||
|
Swing Drag & Drop and DreamWeaver Applications).[/li]\
|
||||||
|
[li][b]Built-in UI controls[/b] − JavaFX library caters UI controls using which we can develop a \
|
||||||
|
full-featured application.[/li]\
|
||||||
|
[li][b]CSS like Styling[/b] − JavaFX provides a CSS like styling. By using this, you can improve \
|
||||||
|
the design of your application with a simple knowledge of CSS.[/li]\
|
||||||
|
[li][b]Canvas and Printing[/b] − JavaFX provides [label style=-fx-background-color:yellow]Canvas[/label], \
|
||||||
|
an immediate mode style of rendering API. Within the package [font=monospace]javafx.scene.canvas[/font] \
|
||||||
|
it holds a set of classes for canvas, using which we can draw directly within an area of the \
|
||||||
|
JavaFX scene. JavaFX also provides classes for Printing purposes in the package javafx.print.[/li]\
|
||||||
|
[li][b]Graphics pipeline[/b] − JavaFX supports graphics based on the hardware-accelerated graphics \
|
||||||
|
pipeline known as Prism. When used with a supported Graphic Card or GPU it offers smooth \
|
||||||
|
graphics. In case the system does not support graphic card then prism defaults to the \
|
||||||
|
software rendering stack.[/li]\
|
||||||
|
[/ul]\
|
||||||
|
[hr/]\
|
||||||
|
[right]Source: [url=https://www.tutorialspoint.com/javafx/javafx_overview.htm]tutorialspoint.com\
|
||||||
|
[/url][/right]""";
|
||||||
|
|
||||||
|
return BBCodeParser.createLayout(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private static class ReferenceBlock extends VBox {
|
||||||
|
|
||||||
|
private final VBox leftBox;
|
||||||
|
private final VBox rightBox;
|
||||||
|
|
||||||
|
public ReferenceBlock(String title, String description) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
var titleLabel = new Label(title);
|
||||||
|
titleLabel.getStyleClass().add(Styles.TITLE_4);
|
||||||
|
|
||||||
|
leftBox = new VBox(15);
|
||||||
|
leftBox.prefWidthProperty().bind(widthProperty().divide(2));
|
||||||
|
leftBox.setPadding(new Insets(10));
|
||||||
|
leftBox.setStyle("""
|
||||||
|
-fx-font-family:monospace;\
|
||||||
|
-fx-background-color:-color-bg-subtle;\
|
||||||
|
-fx-border-width:1px;\
|
||||||
|
-fx-border-color:-color-border-default"""
|
||||||
|
);
|
||||||
|
|
||||||
|
rightBox = new VBox(15);
|
||||||
|
rightBox.prefWidthProperty().bind(widthProperty().divide(2));
|
||||||
|
rightBox.setPadding(new Insets(10));
|
||||||
|
rightBox.setStyle("""
|
||||||
|
-fx-background-color:-color-bg-subtle;\
|
||||||
|
-fx-border-width:1px;\
|
||||||
|
-fx-border-color:-color-border-default"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var splitBox = new HBox(leftBox, rightBox);
|
||||||
|
splitBox.setSpacing(20);
|
||||||
|
|
||||||
|
setSpacing(10);
|
||||||
|
getChildren().addAll(titleLabel, new Label(description), splitBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addFormattedText(String markup) {
|
||||||
|
leftBox.getChildren().add(new TextFlow(new Text(markup)));
|
||||||
|
rightBox.getChildren().add(BBCodeParser.createFormattedText(markup));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addLayout(String markup) {
|
||||||
|
leftBox.getChildren().add(new TextFlow(new Text(markup)));
|
||||||
|
rightBox.getChildren().add(BBCodeParser.createLayout(markup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,6 +14,41 @@ $radius in cfg.$elevation {
|
|||||||
@include effects.shadow(cfg.$elevation-color, cfg.$elevation-interactive);
|
@include effects.shadow(cfg.$elevation-color, cfg.$elevation-interactive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// BBCode //
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
.bb-code {
|
||||||
|
.sub {
|
||||||
|
-fx-translate-y: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sup {
|
||||||
|
-fx-translate-y: -0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr {
|
||||||
|
-fx-border-color: -color-border-default;
|
||||||
|
-fx-border-width: 0 0 1 0;
|
||||||
|
-fx-border-style: solid;
|
||||||
|
-fx-border-insets: 10px 0 10px 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
-fx-font-family: monospace;
|
||||||
|
-fx-border-color: -color-border-default;
|
||||||
|
-fx-border-width: 1;
|
||||||
|
-fx-background-color: -color-bg-subtle;
|
||||||
|
-fx-padding: 0 3 0 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abbr {
|
||||||
|
-fx-border-color: -color-fg-default;
|
||||||
|
-fx-border-width: 0 0 1 0;
|
||||||
|
-fx-border-style: dashed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Ikonli //
|
// Ikonli //
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
Loading…
Reference in New Issue
Block a user