This is an automated email from the ASF dual-hosted git repository.

bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 3cdf70ce49 Issue #8833 : Add a markdown editing toolbar (#8834)
3cdf70ce49 is described below

commit 3cdf70ce49dcf645afccf096b44075c471d8e850
Author: Matt Casters <[email protected]>
AuthorDate: Sat Oct 10 10:32:03 2026 +0200

    Issue #8833 : Add a markdown editing toolbar (#8834)
    
    * Issue #8833 : Add a markdown editing toolbar
    
    Notes and Markdown files can insert links, images, emphasis, headings, code 
blocks, and tables from the editor toolbar.
    
    * Issue #8833 : Place markdown menus on the icon and enable emphasis
    
    SWT Text does not send a selection event, so bold and italic stayed 
disabled. Menus used the toolbar bounds and opened at its left edge.
    
    * Issue #8833 : Undo and redo in the note editor
    
    The note body used a plain text widget whose undo and redo methods do 
nothing. It now uses the styled text editor that already keeps that history.
    
    * Issue #8833 : Open notes with a plain text editor in Hop Web
    
    RAP has no StyledText. The note dialog uses StyledTextComp there and 
StyledTextVar only on the desktop, where undo and redo stay available.
---
 .../modules/ROOT/pages/hop-gui/notes.adoc          |  12 +
 .../hop/core/gui/markdown/MarkdownEditing.java     | 308 +++++++++++
 .../core/gui/markdown/MarkdownTableAlignment.java  |  56 ++
 .../markdown/messages/messages_en_US.properties    |  21 +
 .../hop/core/gui/markdown/MarkdownEditingTest.java | 147 +++++
 .../types/MarkDownExplorerFileTypeHandler.java     |   3 +
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     |  29 +
 .../apache/hop/ui/hopgui/ContentEditorWidget.java  |   7 +
 .../hop/ui/core/widget/IFindReplaceTarget.java     |  12 +
 .../apache/hop/ui/core/widget/StyledTextComp.java  |   6 +
 .../apache/hop/ui/core/widget/StyledTextVar.java   |   6 +
 .../apache/hop/ui/core/widget/TextComposite.java   |  10 +-
 .../core/widget/editor/IContentEditorWidget.java   |  10 +
 .../hop/ui/hopgui/dialog/MarkdownNoteHelp.java     |  27 +
 .../apache/hop/ui/hopgui/dialog/NotePadDialog.java |  28 +-
 .../ui/hopgui/markdown/MarkdownEditActions.java    | 591 +++++++++++++++++++++
 .../ui/hopgui/markdown/MarkdownEditContext.java    |  80 +++
 .../ui/hopgui/markdown/MarkdownTableDialog.java    | 136 +++++
 .../hopgui/markdown/MarkdownTableDialogModel.java  |  81 +++
 .../markdown/messages/messages_en_US.properties    |  51 ++
 ui/src/main/resources/ui/images/bold.svg           |   4 +
 ui/src/main/resources/ui/images/code.svg           |   4 +
 ui/src/main/resources/ui/images/header.svg         |   4 +
 ui/src/main/resources/ui/images/italic.svg         |   4 +
 .../hop/ui/hopgui/dialog/MarkdownNoteHelpTest.java |   3 +
 .../hopgui/markdown/MarkdownEditActionsTest.java   | 209 ++++++++
 26 files changed, 1840 insertions(+), 9 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/notes.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/notes.adoc
index 66c9cc12c9..cbf5355f9a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/notes.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/notes.adoc
@@ -39,6 +39,18 @@ When Markdown is enabled:
 * Use a fixed-width font in the editor for easier source editing.
 * **Preview** opens an HTML rendering of the source (relative image paths are 
expanded so images load in the browser).
 * **Help** opens an in-app guide with examples of supported Markdown.
+* The editor toolbar inserts Markdown. The same buttons are on the toolbar 
when you edit a `.md` file in the file explorer. Bold and italic stay disabled 
until text is selected. With **Render as Markdown** off, the buttons are 
disabled.
+
+The toolbar inserts:
+
+* **Link** — a menu asks for a URL or a file, then inserts `[label](target)`. 
Selected text becomes the label.
+* **Image** — pick a PNG, JPEG, GIF, or SVG. The button inserts `![alt](path)`.
+* **Bold** — `__text__`. **Italic** — `_text_`. Asterisks (`**bold**`, 
`*italic*`) still render.
+* **Table** — choose columns, rows (including the header), and alignment 
(`---`, `:---`, `:---:`, `---:`). The canvas emphasizes the header. HTML 
preview uses the alignment.
+* **Code block** — a fenced block of three backticks, around the selection or 
on a new line.
+* **Header** — Header 1–4, or regular text, on the current line.
+
+A file or image picked while the pipeline, workflow, or Markdown file has a 
name is stored as a path relative to that file.
 
 If Markdown is off, notes behave as classic plain-text notes with customizable 
fonts and colors.
 
diff --git 
a/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownEditing.java 
b/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownEditing.java
new file mode 100644
index 0000000000..7fff292c19
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownEditing.java
@@ -0,0 +1,308 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.gui.markdown;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.provider.UriParser;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+
+/**
+ * Inserts CommonMark snippets for the markdown editing toolbar. Canvas notes 
and {@code .md} files
+ * share these transforms. Bold and italic use underscores, which CommonMark 
renders the same way as
+ * asterisks.
+ */
+public final class MarkdownEditing {
+
+  private static final Pattern ATX = Pattern.compile("^#{1,6}(?:[ \\t]+|$)");
+  private static final Pattern FENCE = 
Pattern.compile("(?s)^```[^\\n]*\\n(.*)\\n```\\n?$");
+
+  private MarkdownEditing() {}
+
+  /**
+   * A replacement of {@code [start, end)} in the editor text. {@code caret} 
is the offset after the
+   * replacement has been applied.
+   */
+  public record Edit(int start, int end, String replacement, int caret) {}
+
+  /** Wrap the selection in {@code __}, or remove that wrap when it is already 
there. */
+  public static Edit toggleBold(String text, int start, int end) {
+    return toggleWrap(text, start, end, "__");
+  }
+
+  /**
+   * Wrap the selection in a single {@code _}, or remove that wrap. A 
selection already wrapped in
+   * {@code __} is not treated as italic.
+   */
+  public static Edit toggleItalic(String text, int start, int end) {
+    return toggleWrap(text, start, end, "_");
+  }
+
+  /**
+   * Insert a fenced code block, or remove the fences when the selection is 
already one block. With
+   * no selection the caret is placed on the blank line between the fences.
+   */
+  public static Edit codeBlock(String text, int start, int end) {
+    int from = lower(text, start, end);
+    int to = upper(text, start, end);
+    if (from == to) {
+      String insertion = "```\n\n```\n";
+      return new Edit(from, to, insertion, from + 4);
+    }
+    String selection = safe(text).substring(from, to);
+    Matcher fenced = FENCE.matcher(selection);
+    if (fenced.matches()) {
+      String inner = fenced.group(1);
+      return new Edit(from, to, inner, from + inner.length());
+    }
+    StringBuilder wrapped = new StringBuilder("```\n");
+    wrapped.append(selection);
+    if (!selection.endsWith("\n")) {
+      wrapped.append('\n');
+    }
+    wrapped.append("```\n");
+    String replacement = wrapped.toString();
+    return new Edit(from, to, replacement, from + replacement.length());
+  }
+
+  /**
+   * Apply an ATX heading to every line touched by the selection. {@code 
level} 1–4 sets that
+   * heading. {@code level} 0 removes a leading heading marker. An existing 
{@code #} … {@code
+   * ######} prefix is replaced, not stacked.
+   */
+  public static Edit applyHeader(String text, int start, int end, int level) {
+    String source = safe(text);
+    int from = lower(source, start, end);
+    int to = upper(source, start, end);
+    int lineStart = from;
+    while (lineStart > 0 && source.charAt(lineStart - 1) != '\n') {
+      lineStart--;
+    }
+    int lineEnd = to;
+    if (from == to) {
+      while (lineEnd < source.length() && source.charAt(lineEnd) != '\n') {
+        lineEnd++;
+      }
+    } else if (!(lineEnd > lineStart && source.charAt(lineEnd - 1) == '\n')) {
+      while (lineEnd < source.length() && source.charAt(lineEnd) != '\n') {
+        lineEnd++;
+      }
+    }
+    String transformed = transformLines(source.substring(lineStart, lineEnd), 
level);
+    return new Edit(lineStart, lineEnd, transformed, lineStart + 
transformed.length());
+  }
+
+  /** {@code [label](destination)}. An empty label uses the destination. */
+  public static String link(String label, String destination) {
+    String visible = Utils.isEmpty(label) ? destinationOrEmpty(destination) : 
label;
+    return "[" + escapeLabel(visible) + "](" + escapeDestination(destination) 
+ ")";
+  }
+
+  /** {@code ![alt](destination)}. An empty alt uses the file name. */
+  public static String image(String alt, String destination) {
+    String visible = Utils.isEmpty(alt) ? fileName(destination) : alt;
+    if (Utils.isEmpty(visible)) {
+      visible = "image";
+    }
+    return "![" + escapeLabel(visible) + "](" + escapeDestination(destination) 
+ ")";
+  }
+
+  /**
+   * GFM pipe table. {@code rowsIncludingHeader} counts the header row and not 
the separator line.
+   * Values below the minimum are raised to 1 column and 2 rows.
+   */
+  public static String table(
+      int columns, int rowsIncludingHeader, MarkdownTableAlignment alignment) {
+    int cols = Math.max(1, columns);
+    int rows = Math.max(2, rowsIncludingHeader);
+    MarkdownTableAlignment align = alignment == null ? 
MarkdownTableAlignment.DEFAULT : alignment;
+    StringBuilder sb = new StringBuilder();
+    appendRow(sb, cols, "");
+    appendRow(sb, cols, align.separator());
+    for (int row = 1; row < rows; row++) {
+      appendRow(sb, cols, "");
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Path to store in Markdown. When {@code baseFilename} is set, the result 
is relative to that
+   * file's parent and uses {@code /} separators. Otherwise {@code selected} 
is returned unchanged.
+   */
+  public static String toMarkdownPath(IVariables variables, String 
baseFilename, String selected) {
+    if (Utils.isEmpty(selected) || Utils.isEmpty(baseFilename)) {
+      return selected;
+    }
+    try {
+      String base = variables != null ? variables.resolve(baseFilename) : 
baseFilename;
+      String picked = variables != null ? variables.resolve(selected) : 
selected;
+      try (FileObject baseFile = HopVfs.getFileObject(base, variables)) {
+        FileObject parent = baseFile.getParent();
+        if (parent == null) {
+          return selected;
+        }
+        try (FileObject pickedFile = HopVfs.getFileObject(picked, variables)) {
+          String relative = 
parent.getName().getRelativeName(pickedFile.getName());
+          if (Utils.isEmpty(relative) || relative.contains("://")) {
+            return selected;
+          }
+          return UriParser.decode(relative).replace('\\', '/');
+        }
+      }
+    } catch (Exception e) {
+      return selected;
+    }
+  }
+
+  /** Last path segment, for a link or image label. */
+  public static String fileName(String path) {
+    if (Utils.isEmpty(path)) {
+      return "";
+    }
+    String normalized = path.replace('\\', '/');
+    int slash = normalized.lastIndexOf('/');
+    String name = slash >= 0 ? normalized.substring(slash + 1) : normalized;
+    int query = name.indexOf('?');
+    if (query >= 0) {
+      name = name.substring(0, query);
+    }
+    if (name.endsWith(">")) {
+      name = name.substring(0, name.length() - 1);
+    }
+    return name;
+  }
+
+  private static Edit toggleWrap(String text, int start, int end, String 
marker) {
+    int from = lower(text, start, end);
+    int to = upper(text, start, end);
+    String selection = safe(text).substring(from, to);
+    String replacement;
+    if (wrapped(selection, marker)) {
+      replacement = selection.substring(marker.length(), selection.length() - 
marker.length());
+    } else {
+      replacement = marker + selection + marker;
+    }
+    return new Edit(from, to, replacement, from + replacement.length());
+  }
+
+  private static boolean wrapped(String selection, String marker) {
+    if ("_".equals(marker)) {
+      return selection.length() >= 2
+          && selection.startsWith("_")
+          && selection.endsWith("_")
+          && !selection.startsWith("__")
+          && !selection.endsWith("__");
+    }
+    return selection.length() >= marker.length() * 2
+        && selection.startsWith(marker)
+        && selection.endsWith(marker);
+  }
+
+  private static String transformLines(String block, int level) {
+    if (block.isEmpty()) {
+      return level <= 0 ? "" : "#".repeat(clampLevel(level)) + " ";
+    }
+    String[] lines = block.split("\\n", -1);
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < lines.length; i++) {
+      if (i > 0) {
+        sb.append('\n');
+      }
+      if (i == lines.length - 1 && lines[i].isEmpty() && block.endsWith("\n")) 
{
+        continue;
+      }
+      sb.append(transformLine(lines[i], level));
+    }
+    return sb.toString();
+  }
+
+  private static String transformLine(String line, int level) {
+    String ending = "";
+    String body = line;
+    if (body.endsWith("\r")) {
+      ending = "\r";
+      body = body.substring(0, body.length() - 1);
+    }
+    String stripped = ATX.matcher(body).replaceFirst("");
+    if (level <= 0) {
+      return stripped + ending;
+    }
+    return "#".repeat(clampLevel(level)) + " " + stripped + ending;
+  }
+
+  private static int clampLevel(int level) {
+    return Math.min(6, Math.max(1, level));
+  }
+
+  private static void appendRow(StringBuilder sb, int columns, String cell) {
+    sb.append('|');
+    for (int column = 0; column < columns; column++) {
+      sb.append(' ').append(cell).append(" |");
+    }
+    sb.append('\n');
+  }
+
+  private static String escapeLabel(String label) {
+    if (label == null) {
+      return "";
+    }
+    return label.replace("\\", "\\\\").replace("]", "\\]");
+  }
+
+  private static String escapeDestination(String destination) {
+    if (destination == null) {
+      return "";
+    }
+    String escaped = destination.replace("\\", "\\\\");
+    boolean angles =
+        escaped.indexOf(' ') >= 0 || escaped.indexOf('(') >= 0 || 
escaped.indexOf(')') >= 0;
+    if (!angles) {
+      return escaped;
+    }
+    return "<" + escaped.replace(">", "\\>") + ">";
+  }
+
+  private static String destinationOrEmpty(String destination) {
+    return destination == null ? "" : destination;
+  }
+
+  private static String safe(String text) {
+    return text == null ? "" : text;
+  }
+
+  private static int lower(String text, int start, int end) {
+    int length = safe(text).length();
+    int from = Math.min(start, end);
+    if (from < 0) {
+      return 0;
+    }
+    return Math.min(from, length);
+  }
+
+  private static int upper(String text, int start, int end) {
+    int length = safe(text).length();
+    int to = Math.max(start, end);
+    if (to < 0) {
+      return 0;
+    }
+    return Math.min(to, length);
+  }
+}
diff --git 
a/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownTableAlignment.java
 
b/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownTableAlignment.java
new file mode 100644
index 0000000000..717df76538
--- /dev/null
+++ 
b/engine/src/main/java/org/apache/hop/core/gui/markdown/MarkdownTableAlignment.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.gui.markdown;
+
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IEnumHasCodeAndDescription;
+
+/**
+ * GFM column alignment written into a Markdown table separator row. One value 
applies to every
+ * column the table dialog inserts.
+ */
+public enum MarkdownTableAlignment implements IEnumHasCodeAndDescription {
+  DEFAULT,
+  LEFT,
+  CENTER,
+  RIGHT;
+
+  @Override
+  public String getCode() {
+    return name();
+  }
+
+  @Override
+  public String getDescription() {
+    return BaseMessages.getString(MarkdownTableAlignment.class, 
"MarkdownTableAlignment." + name());
+  }
+
+  /** Separator cell for this alignment, without the surrounding pipes. */
+  public String separator() {
+    return switch (this) {
+      case LEFT -> ":---";
+      case CENTER -> ":---:";
+      case RIGHT -> "---:";
+      case DEFAULT -> "---";
+    };
+  }
+
+  public static String[] getDescriptions() {
+    return 
IEnumHasCodeAndDescription.getDescriptions(MarkdownTableAlignment.class);
+  }
+}
diff --git 
a/engine/src/main/resources/org/apache/hop/core/gui/markdown/messages/messages_en_US.properties
 
b/engine/src/main/resources/org/apache/hop/core/gui/markdown/messages/messages_en_US.properties
new file mode 100644
index 0000000000..b050b76ded
--- /dev/null
+++ 
b/engine/src/main/resources/org/apache/hop/core/gui/markdown/messages/messages_en_US.properties
@@ -0,0 +1,21 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+MarkdownTableAlignment.DEFAULT=Default (---)
+MarkdownTableAlignment.LEFT=Left (:---)
+MarkdownTableAlignment.CENTER=Center (:---:)
+MarkdownTableAlignment.RIGHT=Right (---:)
diff --git 
a/engine/src/test/java/org/apache/hop/core/gui/markdown/MarkdownEditingTest.java
 
b/engine/src/test/java/org/apache/hop/core/gui/markdown/MarkdownEditingTest.java
new file mode 100644
index 0000000000..e25849ee73
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/core/gui/markdown/MarkdownEditingTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.gui.markdown;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.gui.markdown.MarkdownEditing.Edit;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class MarkdownEditingTest {
+
+  @Test
+  void boldWrapsAndUnwrapsWithDoubleUnderscores() {
+    Edit wrapped = MarkdownEditing.toggleBold("say hi", 4, 6);
+    assertEquals("say __hi__", apply("say hi", wrapped));
+    assertEquals(10, wrapped.caret());
+
+    Edit unwrapped = MarkdownEditing.toggleBold("say __hi__", 4, 10);
+    assertEquals("say hi", apply("say __hi__", unwrapped));
+  }
+
+  @Test
+  void italicUsesASingleUnderscoreAndLeavesBoldAlone() {
+    Edit wrapped = MarkdownEditing.toggleItalic("say hi", 4, 6);
+    assertEquals("say _hi_", apply("say hi", wrapped));
+
+    Edit unwrapped = MarkdownEditing.toggleItalic("say _hi_", 4, 8);
+    assertEquals("say hi", apply("say _hi_", unwrapped));
+
+    Edit boldStaysBold = MarkdownEditing.toggleItalic("__hi__", 0, 6);
+    assertEquals("___hi___", apply("__hi__", boldStaysBold));
+  }
+
+  @Test
+  void codeBlockInsertsFencesAndRemovesThem() {
+    Edit empty = MarkdownEditing.codeBlock("ab", 1, 1);
+    assertEquals("a```\n\n```\nb", apply("ab", empty));
+    assertEquals(5, empty.caret());
+
+    Edit wrapped = MarkdownEditing.codeBlock("int a;", 0, 6);
+    assertEquals("```\nint a;\n```\n", wrapped.replacement());
+
+    String fenced = "```\nint a;\n```\n";
+    Edit unwrapped = MarkdownEditing.codeBlock(fenced, 0, fenced.length());
+    assertEquals("int a;", unwrapped.replacement());
+  }
+
+  @Test
+  void headerRewritesTheTouchedLines() {
+    Edit second = MarkdownEditing.applyHeader("a\nb\nc", 2, 2, 1);
+    assertEquals("a\n# b\nc", apply("a\nb\nc", second));
+
+    Edit both = MarkdownEditing.applyHeader("a\nb", 0, 3, 3);
+    assertEquals("### a\n### b", both.replacement());
+
+    Edit demoted = MarkdownEditing.applyHeader("## Title", 0, 8, 1);
+    assertEquals("# Title", demoted.replacement());
+
+    Edit plain = MarkdownEditing.applyHeader("#### Title", 0, 10, 0);
+    assertEquals("Title", plain.replacement());
+
+    Edit keepsNextLine = MarkdownEditing.applyHeader("hello\nworld", 0, 6, 2);
+    assertEquals("## hello\nworld", apply("hello\nworld", keepsNextLine));
+  }
+
+  @Test
+  void linkAndImageEscapeLabelAndDestination() {
+    assertEquals(
+        "[hop](https://hop.apache.org)", MarkdownEditing.link("hop", 
"https://hop.apache.org";));
+    assertEquals(
+        "[https://hop.apache.org](https://hop.apache.org)",
+        MarkdownEditing.link("", "https://hop.apache.org";));
+    assertEquals("[a\\]b](https://x)", MarkdownEditing.link("a]b", 
"https://x";));
+    assertEquals("[file](<my file.png>)", MarkdownEditing.link("file", "my 
file.png"));
+    assertEquals("[file](<file(1).png>)", MarkdownEditing.link("file", 
"file(1).png"));
+    assertEquals("![a.png](images/a.png)", MarkdownEditing.image("", 
"images/a.png"));
+    assertEquals("![diagram](<my pic.png>)", MarkdownEditing.image("diagram", 
"my pic.png"));
+  }
+
+  @Test
+  void tableIncludesHeaderSeparatorAndBody() {
+    String left = MarkdownEditing.table(2, 3, MarkdownTableAlignment.LEFT);
+    assertEquals("|  |  |\n| :--- | :--- |\n|  |  |\n|  |  |\n", left);
+
+    String center = MarkdownEditing.table(1, 2, MarkdownTableAlignment.CENTER);
+    assertEquals("|  |\n| :---: |\n|  |\n", center);
+
+    String right = MarkdownEditing.table(1, 1, MarkdownTableAlignment.RIGHT);
+    assertEquals("|  |\n| ---: |\n|  |\n", right);
+
+    assertEquals("|  |\n| --- |\n|  |\n", MarkdownEditing.table(0, 0, null));
+    assertEquals("DEFAULT", MarkdownTableAlignment.DEFAULT.getCode());
+    assertTrue(MarkdownTableAlignment.LEFT.getDescription().contains(":---"));
+    assertFalse(MarkdownTableAlignment.getDescriptions().length < 4);
+  }
+
+  @Test
+  void relativePathUsesTheBaseFileParent(@TempDir Path dir) throws Exception {
+    Path notes = dir.resolve("notes");
+    Files.createDirectories(notes.resolve("images"));
+    Path base = notes.resolve("pipeline.hpl");
+    Path image = notes.resolve("images").resolve("my pic.png");
+    Path sibling = notes.resolve("other.hpl");
+    Files.writeString(base, "<pipeline/>");
+    Files.writeString(image, "png");
+    Files.writeString(sibling, "<pipeline/>");
+
+    Variables variables = new Variables();
+    assertEquals(
+        "images/my pic.png",
+        MarkdownEditing.toMarkdownPath(
+            variables, base.toAbsolutePath().toString(), 
image.toAbsolutePath().toString()));
+    assertEquals(
+        "other.hpl",
+        MarkdownEditing.toMarkdownPath(
+            variables, base.toAbsolutePath().toString(), 
sibling.toAbsolutePath().toString()));
+
+    String absolute = image.toAbsolutePath().toString();
+    assertEquals(absolute, MarkdownEditing.toMarkdownPath(variables, null, 
absolute));
+    assertEquals(absolute, MarkdownEditing.toMarkdownPath(variables, "", 
absolute));
+  }
+
+  private static String apply(String text, Edit edit) {
+    return text.substring(0, edit.start()) + edit.replacement() + 
text.substring(edit.end());
+  }
+}
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
index 288bf0c984..cb8675b4ad 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
@@ -31,6 +31,7 @@ import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
 import org.apache.hop.ui.hopgui.ContentEditorFacade;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.markdown.MarkdownEditContext;
 import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
@@ -95,6 +96,8 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
     // The toolbar hands the preview action nothing but the editor widget, so 
leave a way back
     // to this handler on the control itself.
     editorWidget.getControl().setData(EDITOR_DATA_HANDLER, this);
+    MarkdownEditContext.attach(
+        editorWidget.getControl(), hopGui.getVariables(), this::getFilename, 
() -> true);
 
     // If it's a new file, there's no need to reload it
     if (this.getFilename() != null) {
diff --git 
a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index 34d90155c2..58a0fed36a 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -26,6 +26,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.widget.TextIndent;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.markdown.MarkdownEditActions;
 import org.eclipse.rap.json.JsonObject;
 import org.eclipse.rap.rwt.RWT;
 import org.eclipse.rap.rwt.remote.AbstractOperationHandler;
@@ -113,6 +114,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     RapContentEditorWidget widget = new RapContentEditorWidget(root, text, 
languageId);
     Control toolbar = addToolbar(root, widget);
     
text.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+    text.addListener(SWT.Selection, event -> widget.updateToolbar());
     text.addListener(
         SWT.KeyDown,
         event -> {
@@ -209,6 +211,15 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
                 if (properties.get("end") != null) {
                   selectionEnd = properties.get("end").asInt();
                 }
+                Display current = host.getDisplay();
+                if (current != null && !current.isDisposed() && 
!host.isDisposed()) {
+                  current.asyncExec(
+                      () -> {
+                        if (!host.isDisposed()) {
+                          updateToolbar();
+                        }
+                      });
+                }
                 return;
               }
               if ("findRequested".equals(event)) {
@@ -457,6 +468,11 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       return !readOnly;
     }
 
+    @Override
+    public GuiToolbarWidgets getToolbarWidgets() {
+      return toolbarWidgets;
+    }
+
     @Override
     public void updateToolbar() {
       if (toolbarWidgets == null) {
@@ -464,6 +480,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       }
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
       
toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
isEditable());
+      MarkdownEditActions.updateContentEditor(this);
     }
 
     private int clampedOffset(int offset) {
@@ -612,6 +629,17 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       return text.getCaretPosition();
     }
 
+    @Override
+    public int getSelectionStart() {
+      org.eclipse.swt.graphics.Point range = text.getSelection();
+      return range == null ? 0 : range.x;
+    }
+
+    @Override
+    public GuiToolbarWidgets getToolbarWidgets() {
+      return toolbarWidgets;
+    }
+
     @Override
     public void setCaretPosition(int position) {
       text.setSelection(position);
@@ -639,6 +667,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       }
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
       
toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
isEditable());
+      MarkdownEditActions.updateContentEditor(this);
     }
   }
 }
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
index 20e081a2ab..2188be65a1 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -38,6 +38,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.widget.TextIndent;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.markdown.MarkdownEditActions;
 import org.eclipse.jface.text.DocumentEvent;
 import org.eclipse.jface.text.IDocument;
 import org.eclipse.jface.text.IDocumentExtension3;
@@ -301,6 +302,7 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
       toolbarWidgets.enableToolbarItem(
           ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
sourceViewer.isEditable());
+      MarkdownEditActions.updateContentEditor(this);
     }
 
     // Update the HopGui main menu items. There is no main menu when the 
editor is built outside a
@@ -335,6 +337,11 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
     return control;
   }
 
+  @Override
+  public GuiToolbarWidgets getToolbarWidgets() {
+    return toolbarWidgets;
+  }
+
   @Override
   public String getText() {
     IDocument doc = sourceViewer.getDocument();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java
index 836a38eba4..6cea6eb72a 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java
@@ -48,6 +48,18 @@ public interface IFindReplaceTarget {
    */
   int getSelectionCount();
 
+  /**
+   * Start offset of the selection. The caret may sit at either end, so this 
is not {@link
+   * #getCaretPosition()} minus {@link #getSelectionCount()} on every widget.
+   *
+   * @return inclusive start offset
+   */
+  default int getSelectionStart() {
+    int caret = getCaretPosition();
+    int count = getSelectionCount();
+    return Math.max(0, caret - count);
+  }
+
   /**
    * Select the range {@code [start, end)}.
    *
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
index 7430fab1c0..7eec3fd29d 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
@@ -182,6 +182,12 @@ public class StyledTextComp extends TextComposite {
     return textWidget.getCaretPosition();
   }
 
+  @Override
+  public int getSelectionStart() {
+    org.eclipse.swt.graphics.Point range = textWidget.getSelection();
+    return range == null ? 0 : range.x;
+  }
+
   @Override
   public void setCaretPosition(int offset) {
     // Text has no caret offset API; move the selection to a zero-width range.
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
index 4713da8e09..1ecec0b951 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
@@ -290,6 +290,12 @@ public class StyledTextVar extends TextComposite {
     return wText.getSelectionCount();
   }
 
+  @Override
+  public int getSelectionStart() {
+    org.eclipse.swt.graphics.Point range = wText.getSelection();
+    return range == null ? 0 : range.x;
+  }
+
   @Override
   public void setSelection(int start) {
     wText.setSelection(start);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
index 243d856198..4ee24a03ca 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
@@ -34,6 +34,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.markdown.MarkdownEditActions;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.dnd.Clipboard;
@@ -118,6 +119,9 @@ public abstract class TextComposite extends Composite 
implements IFindReplaceTar
   /** Free-form human-readable text / message body. */
   public static final String STYLE_TYPE_TEXT = "Text";
 
+  /** Markdown source, including canvas notes edited as Markdown. */
+  public static final String STYLE_TYPE_MARKDOWN = "Markdown";
+
   /** Cassandra Query Language. */
   public static final String STYLE_TYPE_CQL = "CQL";
 
@@ -290,6 +294,7 @@ public abstract class TextComposite extends Composite 
implements IFindReplaceTar
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_SELECT_ALL, getCharCount() > 
0);
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_FIND, true);
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_FIND_REPLACE, editable);
+    MarkdownEditActions.updateTextComposite(this);
   }
 
   // --- Toolbar actions (static so they resolve for every TextComposite 
subclass) ---
@@ -751,7 +756,10 @@ public abstract class TextComposite extends Composite 
implements IFindReplaceTar
         });
 
     addListener(SWT.Modify, event -> updateToolbar());
-    addListener(SWT.Selection, event -> updateToolbar());
+    // SWT Text never sends SWT.Selection when the user selects text (only 
DefaultSelection
+    // for Enter). Mouse and key release are what actually change the 
selection.
+    addListener(SWT.MouseUp, event -> updateToolbar());
+    addListener(SWT.KeyUp, event -> updateToolbar());
 
     addMenuDetectListener(
         event -> {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
 
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
index 2b32c38bfc..b2397b34b8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.core.widget.editor;
 
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.widget.IFindReplaceTarget;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.ModifyListener;
@@ -237,4 +238,13 @@ public interface IContentEditorWidget extends 
IFindReplaceTarget {
     Control control = getControl();
     return control != null && !control.isDisposed() && control.setFocus();
   }
+
+  /**
+   * Toolbar created for this editor, when it has one.
+   *
+   * @return the toolbar widgets, or {@code null}
+   */
+  default GuiToolbarWidgets getToolbarWidgets() {
+    return null;
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelp.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelp.java
index 114b49fd8a..ef33369190 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelp.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelp.java
@@ -96,6 +96,33 @@ public final class MarkdownNoteHelp {
             + "a note on the canvas to edit it.</li>\n");
     html.append("</ul>\n");
 
+    html.append("<h2>Editing toolbar</h2>\n");
+    html.append(
+        "<p>When <strong>Render as Markdown</strong> is on, the note toolbar 
inserts syntax. "
+            + "The same buttons are on the toolbar when you edit a 
<code>.md</code> file in the "
+            + "file explorer. Bold and italic stay disabled until text is 
selected.</p>\n");
+    html.append("<ul>\n");
+    html.append(
+        "<li><strong>Link</strong>: choose a URL or a file, then insert "
+            + "<code>[label](target)</code>. Selected text becomes the 
label.</li>\n");
+    html.append(
+        "<li><strong>Image</strong>: pick a PNG, JPEG, GIF, or SVG and insert "
+            + "<code>![alt](path)</code>.</li>\n");
+    html.append("<li><strong>Bold</strong>: <code>__text__</code></li>\n");
+    html.append("<li><strong>Italic</strong>: <code>_text_</code></li>\n");
+    html.append(
+        "<li><strong>Table</strong>: rows (including the header), columns, and 
alignment "
+            + "(<code>---</code>, <code>:---</code>, <code>:---:</code>, 
<code>---:</code>). "
+            + "The canvas emphasizes the header. HTML preview uses the 
alignment.</li>\n");
+    html.append("<li><strong>Code block</strong>: a fenced block of three 
backticks.</li>\n");
+    html.append(
+        "<li><strong>Header</strong>: Header 1–4 or regular text on the 
current line.</li>\n");
+    html.append("</ul>\n");
+    html.append(
+        "<p>Asterisks (<code>**bold**</code>, <code>*italic*</code>) still 
render. "
+            + "The toolbar uses underscores. A picked file is stored relative 
to the pipeline, "
+            + "workflow, or Markdown file when that file has a name.</p>\n");
+
     html.append("<h2>Simple examples</h2>\n");
     html.append("<pre>");
     html.append("# Title\n");
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/NotePadDialog.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/NotePadDialog.java
index f3723107b9..1be1ea6934 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/NotePadDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/dialog/NotePadDialog.java
@@ -28,8 +28,11 @@ import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.StyledTextComp;
+import org.apache.hop.ui.core.widget.StyledTextVar;
 import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.hopgui.markdown.MarkdownEditContext;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.apache.hop.ui.util.EnvironmentUtils;
 import org.apache.hop.ui.util.HelpUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CCombo;
@@ -60,7 +63,7 @@ public class NotePadDialog extends Dialog {
 
   private NotePadMeta notePadMeta;
 
-  private StyledTextComp wDesc;
+  private TextComposite wDesc;
 
   private Button wMarkdown;
 
@@ -272,17 +275,23 @@ public class NotePadDialog extends Dialog {
     fdlDesc.left = new FormAttachment(0, 0);
     fdlDesc.top = new FormAttachment(wNoteType, margin);
     wlDesc.setLayoutData(fdlDesc);
-    wDesc =
-        new StyledTextComp(
-            variables,
-            wNoteContentComp,
-            SWT.MULTI | SWT.LEFT | SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL,
-            TextComposite.STYLE_TYPE_TEXT);
+    // Hop Web (RAP) has no StyledText. Desktop uses StyledTextVar so undo and 
redo work.
+    int noteStyle = SWT.MULTI | SWT.LEFT | SWT.BORDER | SWT.H_SCROLL | 
SWT.V_SCROLL;
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      wDesc =
+          new StyledTextComp(
+              variables, wNoteContentComp, noteStyle, 
TextComposite.STYLE_TYPE_MARKDOWN);
+    } else {
+      wDesc =
+          new StyledTextVar(
+              variables, wNoteContentComp, noteStyle, 
TextComposite.STYLE_TYPE_MARKDOWN);
+    }
+    MarkdownEditContext.attach(
+        wDesc, variables, () -> baseFilename, () -> wMarkdown.getSelection());
     wDesc.setText("");
     // Standard widget look (theme-aware). Do not paint note fill/font colors 
into the editor —
     // those are canvas-only and break dark mode. Use a fixed-width font for 
Markdown source.
     PropsUi.setLook(wDesc);
-    PropsUi.setLook(wDesc.getTextWidget());
     wDesc.setFont(GuiResource.getInstance().getFontFixed());
     FormData fdDesc = new FormData();
     fdDesc.left = new FormAttachment(0, 0);
@@ -707,6 +716,9 @@ public class NotePadDialog extends Dialog {
     if (wNoteFontComp != null && !wNoteFontComp.isDisposed()) {
       setEnabledRecursive(wNoteFontComp, !markdown);
     }
+    if (wDesc != null && !wDesc.isDisposed()) {
+      wDesc.updateToolbar();
+    }
   }
 
   private void setEnabledRecursive(Control control, boolean enabled) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActions.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActions.java
new file mode 100644
index 0000000000..209317b45b
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActions.java
@@ -0,0 +1,591 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.markdown;
+
+import org.apache.hop.core.gui.markdown.MarkdownEditing;
+import org.apache.hop.core.gui.markdown.MarkdownEditing.Edit;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.EnterStringDialog;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.widget.IFindReplaceTarget;
+import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+
+/**
+ * Markdown toolbar actions shared by canvas notes ({@link TextComposite}) and 
explorer {@code .md}
+ * files ({@link IContentEditorWidget}). Each action is registered on both 
toolbars. Filters hide
+ * the buttons everywhere else.
+ */
+@GuiPlugin(name = "Markdown editing")
+public class MarkdownEditActions {
+
+  private static final Class<?> PKG = MarkdownEditActions.class;
+
+  public static final String LANGUAGE = "markdown";
+
+  public static final String ID_CONTENT_LINK = 
"ContentEditor-Toolbar-50000-markdown-link";
+  public static final String ID_CONTENT_IMAGE = 
"ContentEditor-Toolbar-50010-markdown-image";
+  public static final String ID_CONTENT_BOLD = 
"ContentEditor-Toolbar-50020-markdown-bold";
+  public static final String ID_CONTENT_ITALIC = 
"ContentEditor-Toolbar-50030-markdown-italic";
+  public static final String ID_CONTENT_TABLE = 
"ContentEditor-Toolbar-50040-markdown-table";
+  public static final String ID_CONTENT_CODE = 
"ContentEditor-Toolbar-50050-markdown-code";
+  public static final String ID_CONTENT_HEADER = 
"ContentEditor-Toolbar-50060-markdown-header";
+
+  public static final String ID_TEXT_LINK = 
"textcomposite-toolbar-10300-markdown-link";
+  public static final String ID_TEXT_IMAGE = 
"textcomposite-toolbar-10310-markdown-image";
+  public static final String ID_TEXT_BOLD = 
"textcomposite-toolbar-10320-markdown-bold";
+  public static final String ID_TEXT_ITALIC = 
"textcomposite-toolbar-10330-markdown-italic";
+  public static final String ID_TEXT_TABLE = 
"textcomposite-toolbar-10340-markdown-table";
+  public static final String ID_TEXT_CODE = 
"textcomposite-toolbar-10350-markdown-code";
+  public static final String ID_TEXT_HEADER = 
"textcomposite-toolbar-10360-markdown-header";
+
+  private static final String[] SELECTION_IDS = {
+    ID_CONTENT_BOLD, ID_CONTENT_ITALIC, ID_TEXT_BOLD, ID_TEXT_ITALIC
+  };
+
+  private static final String[] ALWAYS_IDS = {
+    ID_CONTENT_LINK,
+    ID_CONTENT_IMAGE,
+    ID_CONTENT_TABLE,
+    ID_CONTENT_CODE,
+    ID_CONTENT_HEADER,
+    ID_TEXT_LINK,
+    ID_TEXT_IMAGE,
+    ID_TEXT_TABLE,
+    ID_TEXT_CODE,
+    ID_TEXT_HEADER
+  };
+
+  private static final String[] IMAGE_EXTENSIONS = {
+    "*.png;*.jpg;*.jpeg;*.gif;*.svg", "*.png", "*.jpg;*.jpeg", "*.gif", "*.svg"
+  };
+
+  private MarkdownEditActions() {}
+
+  /** Show the content-editor buttons only while the editor language is 
markdown. */
+  @GuiToolbarElementFilter(parentId = 
IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID)
+  public static boolean showForContentEditor(String itemId, Object 
guiPluginInstance) {
+    if (!isContentEditorItem(itemId)) {
+      return true;
+    }
+    if (!(guiPluginInstance instanceof IContentEditorWidget editor)) {
+      return false;
+    }
+    return LANGUAGE.equalsIgnoreCase(editor.getLanguage());
+  }
+
+  /** Show the note-editor buttons only on a markdown-styled {@link 
TextComposite}. */
+  @GuiToolbarElementFilter(parentId = TextComposite.ID_TOOLBAR)
+  public static boolean showForTextComposite(String itemId, Object 
guiPluginInstance) {
+    if (!(guiPluginInstance instanceof TextComposite text)) {
+      return showForStyle(itemId, null);
+    }
+    return showForStyle(itemId, text.getStyleType());
+  }
+
+  /** Filter predicate used by {@link #showForTextComposite} and unit tests. */
+  public static boolean showForStyle(String itemId, String styleType) {
+    if (!isTextCompositeItem(itemId)) {
+      return true;
+    }
+    return TextComposite.STYLE_TYPE_MARKDOWN.equals(styleType);
+  }
+
+  public static void updateContentEditor(IContentEditorWidget editor) {
+    if (editor == null) {
+      return;
+    }
+    boolean markdown = LANGUAGE.equalsIgnoreCase(editor.getLanguage()) && 
editor.isEditable();
+    enable(editor.getToolbarWidgets(), markdown, editor.getSelectionCount());
+  }
+
+  public static void updateTextComposite(TextComposite text) {
+    if (text == null) {
+      return;
+    }
+    MarkdownEditContext context = MarkdownEditContext.from(text);
+    boolean markdown =
+        TextComposite.STYLE_TYPE_MARKDOWN.equals(text.getStyleType())
+            && (context == null || context.active())
+            && text.isEditable();
+    enable(text.getToolbarWidgets(), markdown, text.getSelectionCount());
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_LINK,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/link.svg",
+      toolTip = "i18n::MarkdownEditActions.Link.Tooltip",
+      separator = true)
+  public static void link(IContentEditorWidget editor) {
+    linkTarget(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_LINK,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/link.svg",
+      toolTip = "i18n::MarkdownEditActions.Link.Tooltip",
+      separator = true)
+  public static void link(TextComposite text) {
+    linkTarget(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_IMAGE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/image.svg",
+      toolTip = "i18n::MarkdownEditActions.Image.Tooltip")
+  public static void image(IContentEditorWidget editor) {
+    insertImage(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_IMAGE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/image.svg",
+      toolTip = "i18n::MarkdownEditActions.Image.Tooltip")
+  public static void image(TextComposite text) {
+    insertImage(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_BOLD,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/bold.svg",
+      toolTip = "i18n::MarkdownEditActions.Bold.Tooltip")
+  public static void bold(IContentEditorWidget editor) {
+    toggleBold(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_BOLD,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/bold.svg",
+      toolTip = "i18n::MarkdownEditActions.Bold.Tooltip")
+  public static void bold(TextComposite text) {
+    toggleBold(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_ITALIC,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/italic.svg",
+      toolTip = "i18n::MarkdownEditActions.Italic.Tooltip")
+  public static void italic(IContentEditorWidget editor) {
+    toggleItalic(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_ITALIC,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/italic.svg",
+      toolTip = "i18n::MarkdownEditActions.Italic.Tooltip")
+  public static void italic(TextComposite text) {
+    toggleItalic(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_TABLE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/table.svg",
+      toolTip = "i18n::MarkdownEditActions.Table.Tooltip")
+  public static void table(IContentEditorWidget editor) {
+    insertTable(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_TABLE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/table.svg",
+      toolTip = "i18n::MarkdownEditActions.Table.Tooltip")
+  public static void table(TextComposite text) {
+    insertTable(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_CODE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/code.svg",
+      toolTip = "i18n::MarkdownEditActions.Code.Tooltip")
+  public static void code(IContentEditorWidget editor) {
+    insertCode(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_CODE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/code.svg",
+      toolTip = "i18n::MarkdownEditActions.Code.Tooltip")
+  public static void code(TextComposite text) {
+    insertCode(text);
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_HEADER,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/header.svg",
+      toolTip = "i18n::MarkdownEditActions.Header.Tooltip")
+  public static void header(IContentEditorWidget editor) {
+    showHeaderMenu(editor);
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXT_HEADER,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/header.svg",
+      toolTip = "i18n::MarkdownEditActions.Header.Tooltip")
+  public static void header(TextComposite text) {
+    showHeaderMenu(text);
+  }
+
+  private static void linkTarget(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    Menu menu = new Menu(shell, SWT.POP_UP);
+    MenuItem urlItem = new MenuItem(menu, SWT.PUSH);
+    urlItem.setText(BaseMessages.getString(PKG, 
"MarkdownEditActions.Link.Url"));
+    urlItem.addListener(SWT.Selection, e -> askUrl(editor));
+    MenuItem fileItem = new MenuItem(menu, SWT.PUSH);
+    fileItem.setText(BaseMessages.getString(PKG, 
"MarkdownEditActions.Link.File"));
+    fileItem.addListener(SWT.Selection, e -> askFile(editor));
+    showPopup(editor, ID_TEXT_LINK, ID_CONTENT_LINK, menu);
+  }
+
+  private static void askUrl(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    String url =
+        new EnterStringDialog(
+                shell,
+                "",
+                BaseMessages.getString(PKG, "MarkdownEditActions.Url.Title"),
+                BaseMessages.getString(PKG, "MarkdownEditActions.Url.Message"))
+            .open();
+    if (Utils.isEmpty(url)) {
+      return;
+    }
+    String label = editor.getSelectionText();
+    insertSnippet(editor, MarkdownEditing.link(Utils.isEmpty(label) ? url : 
label, url.trim()));
+  }
+
+  private static void askFile(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    String picked =
+        BaseDialog.presentFileDialog(
+            shell,
+            null,
+            variablesOf(editor),
+            new String[] {"*.*"},
+            new String[] {BaseMessages.getString(PKG, 
"MarkdownEditActions.File.All")},
+            false);
+    if (Utils.isEmpty(picked)) {
+      return;
+    }
+    String path = markdownPath(editor, picked);
+    String label = editor.getSelectionText();
+    if (Utils.isEmpty(label)) {
+      label = MarkdownEditing.fileName(path);
+    }
+    insertSnippet(editor, MarkdownEditing.link(label, path));
+  }
+
+  private static void insertImage(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    String picked =
+        BaseDialog.presentFileDialog(
+            shell,
+            null,
+            variablesOf(editor),
+            IMAGE_EXTENSIONS,
+            new String[] {
+              BaseMessages.getString(PKG, "MarkdownEditActions.Image.All"),
+              BaseMessages.getString(PKG, "MarkdownEditActions.Image.Png"),
+              BaseMessages.getString(PKG, "MarkdownEditActions.Image.Jpeg"),
+              BaseMessages.getString(PKG, "MarkdownEditActions.Image.Gif"),
+              BaseMessages.getString(PKG, "MarkdownEditActions.Image.Svg")
+            },
+            false);
+    if (Utils.isEmpty(picked)) {
+      return;
+    }
+    String path = markdownPath(editor, picked);
+    String alt = editor.getSelectionText();
+    insertSnippet(editor, MarkdownEditing.image(alt, path));
+  }
+
+  private static void toggleBold(IFindReplaceTarget editor) {
+    if (editor == null || editor.getSelectionCount() <= 0) {
+      return;
+    }
+    int[] range = range(editor);
+    apply(editor, MarkdownEditing.toggleBold(editor.getText(), range[0], 
range[1]));
+  }
+
+  private static void toggleItalic(IFindReplaceTarget editor) {
+    if (editor == null || editor.getSelectionCount() <= 0) {
+      return;
+    }
+    int[] range = range(editor);
+    apply(editor, MarkdownEditing.toggleItalic(editor.getText(), range[0], 
range[1]));
+  }
+
+  private static void insertCode(IFindReplaceTarget editor) {
+    if (editor == null) {
+      return;
+    }
+    int[] range = range(editor);
+    apply(editor, MarkdownEditing.codeBlock(editor.getText(), range[0], 
range[1]));
+  }
+
+  private static void insertTable(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    String table = MarkdownTableDialog.open(shell);
+    if (Utils.isEmpty(table)) {
+      return;
+    }
+    insertSnippet(editor, table);
+  }
+
+  private static void showHeaderMenu(IFindReplaceTarget editor) {
+    Shell shell = shellOf(editor);
+    if (shell == null) {
+      return;
+    }
+    Menu menu = new Menu(shell, SWT.POP_UP);
+    addHeaderItem(menu, editor, 1, "MarkdownEditActions.Header.1");
+    addHeaderItem(menu, editor, 2, "MarkdownEditActions.Header.2");
+    addHeaderItem(menu, editor, 3, "MarkdownEditActions.Header.3");
+    addHeaderItem(menu, editor, 4, "MarkdownEditActions.Header.4");
+    new MenuItem(menu, SWT.SEPARATOR);
+    addHeaderItem(menu, editor, 0, "MarkdownEditActions.Header.Regular");
+    showPopup(editor, ID_TEXT_HEADER, ID_CONTENT_HEADER, menu);
+  }
+
+  private static void addHeaderItem(
+      Menu menu, IFindReplaceTarget editor, int level, String labelKey) {
+    MenuItem item = new MenuItem(menu, SWT.PUSH);
+    item.setText(BaseMessages.getString(PKG, labelKey));
+    item.addListener(
+        SWT.Selection,
+        e -> {
+          int[] range = range(editor);
+          apply(editor, MarkdownEditing.applyHeader(editor.getText(), 
range[0], range[1], level));
+        });
+  }
+
+  private static void insertSnippet(IFindReplaceTarget editor, String snippet) 
{
+    if (editor == null || snippet == null) {
+      return;
+    }
+    int[] range = range(editor);
+    apply(editor, new Edit(range[0], range[1], snippet, range[0] + 
snippet.length()));
+  }
+
+  private static void apply(IFindReplaceTarget editor, Edit edit) {
+    if (editor == null || edit == null || editor.isDisposed() || 
!editor.isEditable()) {
+      return;
+    }
+    editor.setSelection(edit.start(), edit.end());
+    editor.insert(edit.replacement());
+    editor.setCaretPosition(edit.caret());
+    editor.setFocus();
+    editor.updateToolbar();
+  }
+
+  private static int[] range(IFindReplaceTarget editor) {
+    int start = Math.max(0, editor.getSelectionStart());
+    int count = Math.max(0, editor.getSelectionCount());
+    return new int[] {start, start + count};
+  }
+
+  private static String markdownPath(IFindReplaceTarget editor, String picked) 
{
+    MarkdownEditContext context = contextOf(editor);
+    String base = context == null ? null : context.baseFilename();
+    return MarkdownEditing.toMarkdownPath(variablesOf(editor), base, picked);
+  }
+
+  private static IVariables variablesOf(IFindReplaceTarget editor) {
+    MarkdownEditContext context = contextOf(editor);
+    if (context != null && context.variables() != null) {
+      return context.variables();
+    }
+    HopGui hopGui = HopGui.getInstance();
+    return hopGui != null ? hopGui.getVariables() : null;
+  }
+
+  private static MarkdownEditContext contextOf(IFindReplaceTarget editor) {
+    if (editor instanceof TextComposite text) {
+      return MarkdownEditContext.from(text);
+    }
+    if (editor instanceof IContentEditorWidget widget) {
+      return MarkdownEditContext.from(widget.getControl());
+    }
+    return null;
+  }
+
+  private static void enable(GuiToolbarWidgets widgets, boolean markdown, int 
selectionCount) {
+    if (widgets == null) {
+      return;
+    }
+    boolean selected = markdown && selectionCount > 0;
+    for (String id : ALWAYS_IDS) {
+      widgets.enableToolbarItem(id, markdown);
+    }
+    for (String id : SELECTION_IDS) {
+      widgets.enableToolbarItem(id, selected);
+    }
+  }
+
+  static boolean isContentEditorItem(String itemId) {
+    return ID_CONTENT_LINK.equals(itemId)
+        || ID_CONTENT_IMAGE.equals(itemId)
+        || ID_CONTENT_BOLD.equals(itemId)
+        || ID_CONTENT_ITALIC.equals(itemId)
+        || ID_CONTENT_TABLE.equals(itemId)
+        || ID_CONTENT_CODE.equals(itemId)
+        || ID_CONTENT_HEADER.equals(itemId);
+  }
+
+  static boolean isTextCompositeItem(String itemId) {
+    return ID_TEXT_LINK.equals(itemId)
+        || ID_TEXT_IMAGE.equals(itemId)
+        || ID_TEXT_BOLD.equals(itemId)
+        || ID_TEXT_ITALIC.equals(itemId)
+        || ID_TEXT_TABLE.equals(itemId)
+        || ID_TEXT_CODE.equals(itemId)
+        || ID_TEXT_HEADER.equals(itemId);
+  }
+
+  private static Shell shellOf(IFindReplaceTarget editor) {
+    if (editor == null || editor.isDisposed()) {
+      return null;
+    }
+    if (editor instanceof TextComposite text) {
+      return text.isDisposed() ? null : text.getShell();
+    }
+    if (editor instanceof IContentEditorWidget widget) {
+      Control control = widget.getControl();
+      return control == null || control.isDisposed() ? null : 
control.getShell();
+    }
+    return null;
+  }
+
+  private static GuiToolbarWidgets toolbarWidgets(IFindReplaceTarget editor) {
+    if (editor instanceof TextComposite text) {
+      return text.getToolbarWidgets();
+    }
+    if (editor instanceof IContentEditorWidget widget) {
+      return widget.getToolbarWidgets();
+    }
+    return null;
+  }
+
+  private static void showPopup(
+      IFindReplaceTarget editor, String textId, String contentId, Menu menu) {
+    String itemId = editor instanceof TextComposite ? textId : contentId;
+    positionUnderToolbarItem(toolbarWidgets(editor), itemId, menu);
+    menu.addListener(
+        SWT.Hide,
+        e -> {
+          Display display = menu.getDisplay();
+          if (display != null && !display.isDisposed()) {
+            display.asyncExec(
+                () -> {
+                  if (!menu.isDisposed()) {
+                    menu.dispose();
+                  }
+                });
+          }
+        });
+    menu.setVisible(true);
+  }
+
+  /**
+   * Drop the menu under the clicked icon. A desktop toolbar button is a 
{@link ToolItem}, and the
+   * control stored for it is the toolbar itself, whose bounds sit at the left 
edge.
+   */
+  private static void positionUnderToolbarItem(
+      GuiToolbarWidgets widgets, String itemId, Menu menu) {
+    if (widgets == null || itemId == null) {
+      return;
+    }
+    ToolItem toolItem = widgets.findToolItem(itemId);
+    if (toolItem != null && !toolItem.isDisposed()) {
+      Rectangle rect = toolItem.getBounds();
+      Point location = toolItem.getParent().toDisplay(rect.x, rect.y + 
rect.height);
+      menu.setLocation(location);
+      return;
+    }
+    Control control = widgets.getControlForMenu(itemId);
+    if (control == null
+        || control.isDisposed()
+        || control.getParent() == null
+        || control instanceof ToolBar) {
+      return;
+    }
+    Rectangle rect = control.getBounds();
+    Point location = control.getParent().toDisplay(rect.x, rect.y + 
rect.height);
+    menu.setLocation(location);
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditContext.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditContext.java
new file mode 100644
index 0000000000..e0b9b8a615
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditContext.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.markdown;
+
+import java.util.function.BooleanSupplier;
+import java.util.function.Supplier;
+import org.apache.hop.core.variables.IVariables;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * Where a markdown editor should resolve file links and images, and whether 
markdown actions are
+ * active. Suppliers are read at click time so a note checkbox or a renamed 
file stays current.
+ */
+public final class MarkdownEditContext {
+
+  public static final String DATA_KEY = MarkdownEditContext.class.getName();
+
+  private final IVariables variables;
+  private final Supplier<String> baseFilename;
+  private final BooleanSupplier active;
+
+  private MarkdownEditContext(
+      IVariables variables, Supplier<String> baseFilename, BooleanSupplier 
active) {
+    this.variables = variables;
+    this.baseFilename = baseFilename;
+    this.active = active;
+  }
+
+  public static void attach(
+      Widget widget, IVariables variables, Supplier<String> baseFilename, 
BooleanSupplier active) {
+    if (widget == null || widget.isDisposed()) {
+      return;
+    }
+    widget.setData(
+        DATA_KEY,
+        new MarkdownEditContext(
+            variables,
+            baseFilename == null ? () -> null : baseFilename,
+            active == null ? () -> true : active));
+  }
+
+  public static MarkdownEditContext from(Widget widget) {
+    if (widget == null || widget.isDisposed()) {
+      return null;
+    }
+    Object data = widget.getData(DATA_KEY);
+    return data instanceof MarkdownEditContext context ? context : null;
+  }
+
+  public IVariables variables() {
+    return variables;
+  }
+
+  public String baseFilename() {
+    return baseFilename.get();
+  }
+
+  public boolean active() {
+    try {
+      return active.getAsBoolean();
+    } catch (Exception e) {
+      return false;
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialog.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialog.java
new file mode 100644
index 0000000000..1fafdcada4
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialog.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.markdown;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.gui.markdown.MarkdownEditing;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+/** Asks for the size and alignment of a Markdown table, then returns the 
pipe-table source. */
+public class MarkdownTableDialog extends Dialog {
+
+  private static final Class<?> PKG = MarkdownTableDialogModel.class;
+
+  private final MarkdownTableDialogModel model = new 
MarkdownTableDialogModel();
+
+  private Shell shell;
+  private GuiCompositeWidgets widgets;
+  private String markdown;
+
+  public MarkdownTableDialog(Shell parent) {
+    super(parent, SWT.NONE);
+  }
+
+  /**
+   * @return the table source, or {@code null} when cancelled
+   */
+  public static String open(Shell parent) {
+    return new MarkdownTableDialog(parent).openDialog();
+  }
+
+  private String openDialog() {
+    Shell parent = getParent();
+    shell = new Shell(parent, BaseDialog.getDefaultDialogStyle());
+    PropsUi.setLook(shell);
+    shell.setText(BaseMessages.getString(PKG, 
"MarkdownTableDialog.Shell.Title"));
+    if (parent.getImage() != null) {
+      shell.setImage(parent.getImage());
+    }
+
+    FormLayout formLayout = new FormLayout();
+    formLayout.marginWidth = PropsUi.getFormMargin();
+    formLayout.marginHeight = PropsUi.getFormMargin();
+    shell.setLayout(formLayout);
+
+    int margin = PropsUi.getMargin();
+
+    Label header = new Label(shell, SWT.LEFT | SWT.WRAP);
+    PropsUi.setLook(header);
+    header.setText(BaseMessages.getString(PKG, "MarkdownTableDialog.Header"));
+    FormData headerData = new FormData();
+    headerData.left = new FormAttachment(0, 0);
+    headerData.top = new FormAttachment(0, 0);
+    headerData.right = new FormAttachment(100, 0);
+    header.setLayoutData(headerData);
+
+    Button ok = new Button(shell, SWT.PUSH);
+    ok.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+    ok.addListener(SWT.Selection, e -> accept());
+
+    Button cancel = new Button(shell, SWT.PUSH);
+    cancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+    cancel.addListener(SWT.Selection, e -> shell.dispose());
+
+    BaseTransformDialog.positionBottomButtons(shell, new Button[] {ok, 
cancel}, margin, null);
+
+    widgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            shell,
+            new Variables(),
+            header,
+            ok,
+            MarkdownTableDialogModel.GUI_PLUGIN_ELEMENT_PARENT_ID,
+            model);
+
+    BaseDialog.defaultShellHandling(shell, c -> accept(), c -> 
shell.dispose(), false);
+    return markdown;
+  }
+
+  private void accept() {
+    if (widgets == null || shell == null || shell.isDisposed()) {
+      return;
+    }
+    widgets.getWidgetsContents(model, 
MarkdownTableDialogModel.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    Integer columns = wholeNumber(model.getColumns());
+    Integer rows = wholeNumber(model.getRows());
+    if (columns == null || rows == null || columns < 1 || rows < 2) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      box.setText(BaseMessages.getString(PKG, 
"MarkdownTableDialog.Error.Title"));
+      box.setMessage(BaseMessages.getString(PKG, 
"MarkdownTableDialog.Error.Message"));
+      box.open();
+      return;
+    }
+    markdown = MarkdownEditing.table(columns, rows, model.getAlignment());
+    shell.dispose();
+  }
+
+  private static Integer wholeNumber(String text) {
+    if (StringUtils.isBlank(text)) {
+      return null;
+    }
+    try {
+      return Integer.valueOf(text.trim());
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialogModel.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialogModel.java
new file mode 100644
index 0000000000..831558829c
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/markdown/MarkdownTableDialogModel.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.markdown;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.gui.markdown.MarkdownTableAlignment;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/** Fields for {@link MarkdownTableDialog}. Not a stored metadata type. */
+@GuiPlugin(description = "Markdown table dialog")
+@Getter
+@Setter
+public class MarkdownTableDialogModel {
+
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
"MarkdownTableDialog-Widgets";
+
+  public static final String WIDGET_COLUMNS = "markdown-table-columns";
+  public static final String WIDGET_ROWS = "markdown-table-rows";
+  public static final String WIDGET_ALIGNMENT = "markdown-table-alignment";
+
+  private static final String GROUP = "i18n::MarkdownTableDialog.Group";
+
+  @GuiWidgetElement(
+      id = WIDGET_COLUMNS,
+      order = "0100",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      type = GuiElementType.TEXT,
+      variables = false,
+      label = "i18n::MarkdownTableDialog.Columns.Label",
+      toolTip = "i18n::MarkdownTableDialog.Columns.Tooltip",
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP)
+  @HopMetadataProperty
+  private String columns = "2";
+
+  @GuiWidgetElement(
+      id = WIDGET_ROWS,
+      order = "0200",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      type = GuiElementType.TEXT,
+      variables = false,
+      label = "i18n::MarkdownTableDialog.Rows.Label",
+      toolTip = "i18n::MarkdownTableDialog.Rows.Tooltip",
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP)
+  @HopMetadataProperty
+  private String rows = "3";
+
+  @GuiWidgetElement(
+      id = WIDGET_ALIGNMENT,
+      order = "0300",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      type = GuiElementType.COMBO,
+      variables = false,
+      label = "i18n::MarkdownTableDialog.Alignment.Label",
+      toolTip = "i18n::MarkdownTableDialog.Alignment.Tooltip",
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP)
+  @HopMetadataProperty
+  private MarkdownTableAlignment alignment = MarkdownTableAlignment.DEFAULT;
+}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/markdown/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/markdown/messages/messages_en_US.properties
new file mode 100644
index 0000000000..3cecacb467
--- /dev/null
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/markdown/messages/messages_en_US.properties
@@ -0,0 +1,51 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+MarkdownEditActions.Link.Tooltip=Insert a link
+MarkdownEditActions.Image.Tooltip=Insert an image
+MarkdownEditActions.Bold.Tooltip=Bold
+MarkdownEditActions.Italic.Tooltip=Italic
+MarkdownEditActions.Table.Tooltip=Insert a table
+MarkdownEditActions.Code.Tooltip=Insert a code block
+MarkdownEditActions.Header.Tooltip=Heading
+MarkdownEditActions.Link.Url=Link to a URL
+MarkdownEditActions.Link.File=Link to a file
+MarkdownEditActions.Url.Title=Link to a URL
+MarkdownEditActions.Url.Message=URL
+MarkdownEditActions.File.All=All files
+MarkdownEditActions.Image.All=Images (PNG, JPEG, GIF, SVG)
+MarkdownEditActions.Image.Png=PNG
+MarkdownEditActions.Image.Jpeg=JPEG
+MarkdownEditActions.Image.Gif=GIF
+MarkdownEditActions.Image.Svg=SVG
+MarkdownEditActions.Header.1=Header 1
+MarkdownEditActions.Header.2=Header 2
+MarkdownEditActions.Header.3=Header 3
+MarkdownEditActions.Header.4=Header 4
+MarkdownEditActions.Header.Regular=Regular text
+
+MarkdownTableDialog.Shell.Title=Insert table
+MarkdownTableDialog.Header=Insert a Markdown table. The separator row is 
written for you and carries the alignment.
+MarkdownTableDialog.Group=Table
+MarkdownTableDialog.Columns.Label=Columns
+MarkdownTableDialog.Columns.Tooltip=Number of columns. At least 1.
+MarkdownTableDialog.Rows.Label=Rows
+MarkdownTableDialog.Rows.Tooltip=Number of rows, including the header row. At 
least 2. The separator line under the header is not counted.
+MarkdownTableDialog.Alignment.Label=Alignment
+MarkdownTableDialog.Alignment.Tooltip=Column alignment written into the 
separator row: --- , :--- , :---: or ---:. The canvas emphasizes the header. 
HTML preview uses the alignment.
+MarkdownTableDialog.Error.Title=Insert table
+MarkdownTableDialog.Error.Message=Enter a whole number of columns (1 or more) 
and rows (2 or more, including the header).
diff --git a/ui/src/main/resources/ui/images/bold.svg 
b/ui/src/main/resources/ui/images/bold.svg
new file mode 100644
index 0000000000..ef4191fc7b
--- /dev/null
+++ b/ui/src/main/resources/ui/images/bold.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+  <path fill="#0F3B5A" d="M7 4.5h6.1c2.3 0 3.8 1.3 3.8 3.3 0 1.4-.8 2.5-2 3 
1.5.4 2.4 1.6 2.4 3.3 0 2.2-1.7 3.6-4.3 3.6H7V4.5zm2.2 1.8v3.4h3.5c1.1 0 1.8-.6 
1.8-1.7s-.7-1.7-1.8-1.7H9.2zm0 5.2v3.7h4c1.3 0 2.1-.6 2.1-1.85S14.5 11.5 13.2 
11.5H9.2z"/>
+</svg>
diff --git a/ui/src/main/resources/ui/images/code.svg 
b/ui/src/main/resources/ui/images/code.svg
new file mode 100644
index 0000000000..d5052c2818
--- /dev/null
+++ b/ui/src/main/resources/ui/images/code.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+  <path fill="#0F3B5A" d="M9.3 7.1L4.4 12l4.9 4.9-1.6 1.6L1.2 12l6.5-6.5 1.6 
1.6zm5.4 0l1.6-1.6L22.8 12l-6.5 6.5-1.6-1.6 4.9-4.9-4.9-4.9z"/>
+</svg>
diff --git a/ui/src/main/resources/ui/images/header.svg 
b/ui/src/main/resources/ui/images/header.svg
new file mode 100644
index 0000000000..b744be3504
--- /dev/null
+++ b/ui/src/main/resources/ui/images/header.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+  <path fill="#0F3B5A" d="M5 5h2.4v5.2h9.2V5H19v14h-2.4v-6.4H7.4V19H5V5z"/>
+</svg>
diff --git a/ui/src/main/resources/ui/images/italic.svg 
b/ui/src/main/resources/ui/images/italic.svg
new file mode 100644
index 0000000000..c8f69dcca3
--- /dev/null
+++ b/ui/src/main/resources/ui/images/italic.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+  <path fill="#0F3B5A" d="M14 4.5h5.2v2.1h-2.7l-4.1 
10.8h2.8v2.1H9.2v-2.1h2.5l4.1-10.8H14V4.5z"/>
+</svg>
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelpTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelpTest.java
index f2a30cbfce..1555b379b1 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelpTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/dialog/MarkdownNoteHelpTest.java
@@ -33,5 +33,8 @@ class MarkdownNoteHelpTest {
     assertTrue(html.contains(".hpl"));
     assertTrue(html.contains("What is supported"));
     assertTrue(html.contains("What is not supported"));
+    assertTrue(html.contains("Editing toolbar"));
+    assertTrue(html.contains("__text__"));
+    assertTrue(html.contains(".md"));
   }
 }
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActionsTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActionsTest.java
new file mode 100644
index 0000000000..7d9ecc7d1d
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/markdown/MarkdownEditActionsTest.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.markdown;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.widgets.Control;
+import org.junit.jupiter.api.Test;
+
+class MarkdownEditActionsTest {
+
+  @Test
+  void toolbarButtonsAreRegisteredOnBothEditors() throws Exception {
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("link", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_LINK,
+        true);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("image", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_IMAGE,
+        false);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("bold", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_BOLD,
+        false);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("italic", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_ITALIC,
+        false);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("table", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_TABLE,
+        false);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("code", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_CODE,
+        false);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("header", 
IContentEditorWidget.class),
+        IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+        MarkdownEditActions.ID_CONTENT_HEADER,
+        false);
+
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("link", TextComposite.class),
+        TextComposite.ID_TOOLBAR,
+        MarkdownEditActions.ID_TEXT_LINK,
+        true);
+    assertToolbar(
+        MarkdownEditActions.class.getMethod("header", TextComposite.class),
+        TextComposite.ID_TOOLBAR,
+        MarkdownEditActions.ID_TEXT_HEADER,
+        false);
+  }
+
+  @Test
+  void filtersFollowLanguageAndStyle() {
+    assertTrue(
+        MarkdownEditActions.showForContentEditor(
+            MarkdownEditActions.ID_CONTENT_BOLD, editor("markdown")));
+    assertFalse(
+        MarkdownEditActions.showForContentEditor(
+            MarkdownEditActions.ID_CONTENT_BOLD, editor("json")));
+    assertTrue(
+        MarkdownEditActions.showForContentEditor(
+            "ContentEditor-Toolbar-10000-undo", editor("json")));
+    assertFalse(
+        
MarkdownEditActions.showForContentEditor(MarkdownEditActions.ID_CONTENT_LINK, 
"markdown"));
+
+    assertTrue(
+        MarkdownEditActions.showForStyle(
+            MarkdownEditActions.ID_TEXT_TABLE, 
TextComposite.STYLE_TYPE_MARKDOWN));
+    assertFalse(
+        MarkdownEditActions.showForStyle(
+            MarkdownEditActions.ID_TEXT_TABLE, TextComposite.STYLE_TYPE_TEXT));
+    assertTrue(
+        MarkdownEditActions.showForStyle(
+            TextComposite.ID_TOOLBAR_UNDO, TextComposite.STYLE_TYPE_TEXT));
+    assertFalse(
+        
MarkdownEditActions.showForTextComposite(MarkdownEditActions.ID_TEXT_BOLD, new 
Object()));
+    assertTrue(
+        
MarkdownEditActions.showForTextComposite(TextComposite.ID_TOOLBAR_COPY, new 
Object()));
+  }
+
+  private static void assertToolbar(Method method, String root, String id, 
boolean separator) {
+    GuiToolbarElement element = method.getAnnotation(GuiToolbarElement.class);
+    assertNotNull(element, method.getName());
+    assertEquals(root, element.root());
+    assertEquals(id, element.id());
+    assertEquals(separator, element.separator());
+    assertTrue(element.image().startsWith("ui/images/"));
+  }
+
+  private static IContentEditorWidget editor(String language) {
+    return new IContentEditorWidget() {
+      @Override
+      public Control getControl() {
+        return null;
+      }
+
+      @Override
+      public String getText() {
+        return "";
+      }
+
+      @Override
+      public void setText(String text) {}
+
+      @Override
+      public void setTextSuppressModify(String text) {}
+
+      @Override
+      public String getLanguage() {
+        return language;
+      }
+
+      @Override
+      public void setLanguage(String languageId) {}
+
+      @Override
+      public void setReadOnly(boolean readOnly) {}
+
+      @Override
+      public void addModifyListener(ModifyListener listener) {}
+
+      @Override
+      public void removeModifyListener(ModifyListener listener) {}
+
+      @Override
+      public void selectAll() {}
+
+      @Override
+      public void unselectAll() {}
+
+      @Override
+      public void copy() {}
+
+      @Override
+      public void cut() {}
+
+      @Override
+      public void paste() {}
+
+      @Override
+      public void undo() {}
+
+      @Override
+      public void redo() {}
+
+      @Override
+      public String getSelectionText() {
+        return "";
+      }
+
+      @Override
+      public int getSelectionCount() {
+        return 0;
+      }
+
+      @Override
+      public void setSelection(int start, int end) {}
+
+      @Override
+      public int getCaretPosition() {
+        return 0;
+      }
+
+      @Override
+      public void setCaretPosition(int position) {}
+
+      @Override
+      public void insert(String text) {}
+
+      @Override
+      public boolean isEditable() {
+        return true;
+      }
+    };
+  }
+}

Reply via email to