diff --git a/CHANGELOG.md b/CHANGELOG.md index c5be3b4..318209d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - (Base) New `DeckPane` component with swipe and slide transition support. - (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input. - (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 [Dracula](https://ui.draculatheme.com/) theme. - (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one). diff --git a/base/src/main/java/atlantafx/base/util/BBCodeHandler.java b/base/src/main/java/atlantafx/base/util/BBCodeHandler.java new file mode 100644 index 0000000..3346a5f --- /dev/null +++ b/base/src/main/java/atlantafx/base/util/BBCodeHandler.java @@ -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 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.

+ * + *

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.

+ * + *

Supported tags


+ *
+     * 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/]
+     * 
+ * + * + * + *

Action Events


+ * Some nodes, e.g. {@link Hyperlink} require action handlers. To avoid traversing + * the root node graph you can add an event filter. + * + *
{@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();
+     * });}
+     * 
+ */ + class Default implements BBCodeHandler { + + protected static final int OL_LETTER_OFFSET = 100_000; + + protected final Block root; + protected char[] doc; + protected Deque openTags = new ArrayDeque<>(); + protected Deque 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 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 params) { + Tag.Type tagType = null; + + // all styles added here will be inherited by nested tags + Set stylesClass = new HashSet<>(); + Set 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 params, String name, String key, Collection c) { + if (params != null && params.containsKey(key)) { + c.add(name + ":" + params.get(key)); + } + } + + protected void addStyleIfPresent(Map params, String key, Collection c, String sep) { + if (params != null && params.containsKey(key)) { + Collections.addAll(c, params.get(key).split(sep)); + } + } + + protected > E getEnumValue(Class 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 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 getStyleClass() { + // set keys cannot be overwritten, + // if parent contains the same style class, child wins + Iterator it = openTags.descendingIterator(); + Set 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 it = openTags.descendingIterator(); + Set 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 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 params, + Set styleClasses, + Set 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; + } + } +} diff --git a/base/src/main/java/atlantafx/base/util/BBCodeParser.java b/base/src/main/java/atlantafx/base/util/BBCodeParser.java new file mode 100644 index 0000000..9d88a9c --- /dev/null +++ b/base/src/main/java/atlantafx/base/util/BBCodeParser.java @@ -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 BBCode markup. + * As the content is parsed, methods of {@link BBCodeHandler} are called.

+ * + *

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 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 processedTags; + private final Deque 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 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 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 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 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. + *

+     * "foo bar" = ["foo", "bar"]
+     * "foo 'bar baz'" = ["foo", "bar baz"]
+     * 
+ */ + protected List splitBySpace(String str, int start, int length) { + var tokens = new ArrayList(); + 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; + } +} diff --git a/base/src/test/java/atlantafx/base/util/BBCodeParserTest.java b/base/src/test/java/atlantafx/base/util/BBCodeParserTest.java new file mode 100644 index 0000000..d2f4c9b --- /dev/null +++ b/base/src/test/java/atlantafx/base/util/BBCodeParserTest.java @@ -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 params, + @Nullable String text) { + } + + public static class BBCodeMockHandler implements BBCodeHandler { + + private static final Set SELF_CLOSE_TAGS = Set.of("hr", "img"); + private static final boolean DEBUG = false; + private static final Map PARAMS_PLACEHOLDER = new HashMap<>(); + + private final List tags = new ArrayList<>(); + private final List text = new ArrayList<>(); + private final Deque textStart = new ArrayDeque<>(); + private final Deque> 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 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 tags() { + return tags; + } + + public List 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; + } + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java index 81da475..9e38c8a 100644 --- a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java +++ b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java @@ -7,6 +7,7 @@ import static atlantafx.sampler.layout.MainModel.SubLayer.SOURCE_CODE; import atlantafx.sampler.page.Page; import atlantafx.sampler.page.components.AccordionPage; +import atlantafx.sampler.page.components.BBCodePage; import atlantafx.sampler.page.components.BreadcrumbsPage; import atlantafx.sampler.page.components.ButtonPage; import atlantafx.sampler.page.components.ChartPage; @@ -186,6 +187,7 @@ public class MainModel { var extras = NavTree.Item.group("Extras", new FontIcon(Material2OutlinedMZ.TOGGLE_ON)); extras.getChildren().setAll( NAV_TREE.get(InputGroupPage.class), + NAV_TREE.get(BBCodePage.class), NAV_TREE.get(BreadcrumbsPage.class), NAV_TREE.get(CustomTextFieldPage.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(BreadcrumbsPage.class, NavTree.Item.page(BreadcrumbsPage.NAME, BreadcrumbsPage.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(CheckBoxPage.class, NavTree.Item.page(CheckBoxPage.NAME, CheckBoxPage.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(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( - FileManagerPage.NAME, + WidgetCollectionPage.NAME, WidgetCollectionPage.class, "Card", "Message", "Stepper", "Tag") ); diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/BBCodePage.java b/sampler/src/main/java/atlantafx/sampler/page/components/BBCodePage.java new file mode 100644 index 0000000..a46b191 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/components/BBCodePage.java @@ -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)); + } + } +} diff --git a/styles/src/general/_extras.scss b/styles/src/general/_extras.scss index f151195..2fa438f 100644 --- a/styles/src/general/_extras.scss +++ b/styles/src/general/_extras.scss @@ -14,6 +14,41 @@ $radius in cfg.$elevation { @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 // /////////////////////////////////////////////////////////////////////////////// @@ -56,4 +91,4 @@ $radius in cfg.$elevation { .ikonli-font-icon:danger { -fx-fill: -color-danger-emphasis; -fx-icon-color: -color-danger-emphasis; -} \ No newline at end of file +}