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 ``.
+* **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 }. 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 "
+ ")";
+ }
+
+ /**
+ * 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("", MarkdownEditing.image("",
"images/a.png"));
+ assertEquals("", 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></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;
+ }
+ };
+ }
+}