From 89749d267a6efc52d58bab5ccdfab36296d2741a Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 6 Nov 2024 12:47:46 +1100 Subject: [PATCH] FEATURE: core support for tabbed interface inside markdown This is useful for AI features and documentation. Adds support for: ``` [tabs] [tab="tab name"] content **here** [/tab] [tab="tab2 name"] other content **here** [/tab] [/tabs] ``` Optionally you can select the default tab using: ``` [tabs] [tab="tab name"] content **here** [/tab] [tab="tab2 name" selected] other content **here** [/tab] [/tabs] ``` --- .../src/features/index.js | 2 + .../src/features/tabs.js | 131 ++++++++++++++++++ .../instance-initializers/markdown-tabs.js | 50 +++++++ .../stylesheets/common/base/_index.scss | 1 + .../common/base/markdown-tabs.scss | 50 +++++++ spec/lib/pretty_text_spec.rb | 78 +++++++++++ 6 files changed, 312 insertions(+) create mode 100644 app/assets/javascripts/discourse-markdown-it/src/features/tabs.js create mode 100644 app/assets/javascripts/discourse/app/instance-initializers/markdown-tabs.js create mode 100644 app/assets/stylesheets/common/base/markdown-tabs.scss diff --git a/app/assets/javascripts/discourse-markdown-it/src/features/index.js b/app/assets/javascripts/discourse-markdown-it/src/features/index.js index f9e7ecf1915..65af16f16e2 100644 --- a/app/assets/javascripts/discourse-markdown-it/src/features/index.js +++ b/app/assets/javascripts/discourse-markdown-it/src/features/index.js @@ -16,6 +16,7 @@ import * as onebox from "./onebox"; import * as paragraph from "./paragraph"; import * as quotes from "./quotes"; import * as table from "./table"; +import * as tabs from "./tabs"; import * as textPostProcess from "./text-post-process"; import * as uploadProtocol from "./upload-protocol"; import * as watchedWords from "./watched-words"; @@ -42,6 +43,7 @@ export default [ feature("bbcode-inline", bbcodeInline), feature("bbcode-block", bbcodeBlock), feature("anchor", anchor), + feature("tabs", tabs), ]; function feature(id, { setup, priority = 0 }) { diff --git a/app/assets/javascripts/discourse-markdown-it/src/features/tabs.js b/app/assets/javascripts/discourse-markdown-it/src/features/tabs.js new file mode 100644 index 00000000000..8478f15de3c --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/features/tabs.js @@ -0,0 +1,131 @@ +const WRAP_CLASS = "markdown-tabs"; + +function setupTabs(helper) { + helper.allowList([ + "div.markdown-tabs", + "div.markdown-tabs-wrapper", + "div.markdown-tab", + "div.markdown-tab-panels", + "div.markdown-tab-panel", + "a[data-tab-id]", + "div[data-tab-id]", + "div[data-selected]", + ]); + + helper.registerPlugin((md) => { + const ruler = md.block.bbcode.ruler; + + ruler.push("tabs", { + tag: "tabs", + before(state) { + state.env.tabContent = []; + + const token = state.push("tab_open", "div", 1); + token.attrs = [["class", WRAP_CLASS]]; + + const wrapperToken = state.push("tab_wrapper_open", "div", 1); + wrapperToken.attrs = [["class", "markdown-tabs-wrapper"]]; + }, + + after(state) { + if (state.env.tabContent) { + // force selection of 1 tab only + let selected = false; + state.env.tabContent.forEach((tab) => { + if (tab.selected) { + if (selected) { + tab.selected = false; + } else { + selected = true; + } + } + }); + if (!selected) { + state.env.tabContent[0].selected = true; + } + + let index = 0; + state.env.tabContent.forEach((tab) => { + const tabId = `tab-${index}`; + index++; + + tab.id = tabId; + + const tabToken = state.push("tab_button_open", "div", 1); + tabToken.attrs = [ + ["class", "markdown-tab"], + ["data-tab-id", tabId], + ]; + if (tab.selected) { + tabToken.attrs.push(["data-selected", ""]); + } + + const linkToken = state.push("tab_link_open", "a", 1); + linkToken.attrs = [["data-tab-id", tabId]]; + + // Add tab name + const textToken = state.push("text", "", 0); + textToken.content = tab.name; + + state.push("tab_link_close", "a", -1); + state.push("tab_button_close", "div", -1); + }); + } + + state.push("tab_wrapper_close", "div", -1); + state.push("panels_open", "div", 1).attrs = [ + ["class", "markdown-tab-panels"], + ]; + + if (state.env.tabContent) { + state.env.tabContent.forEach((tab) => { + const panelToken = state.push("tab_panel_open", "div", 1); + panelToken.attrs = [ + ["class", "markdown-tab-panel"], + ["data-tab-id", tab.id], + ]; + if (tab.selected) { + panelToken.attrs.push(["data-selected", ""]); + } + + if (tab.content) { + tab.content.forEach((token) => state.tokens.push(token)); + } + + state.push("tab_panel_close", "div", -1); + }); + } + + state.env.tabContent = null; + + state.push("panels_close", "div", -1); + state.push("tab_close", "div", -1); + }, + }); + + ruler.push("tab", { + tag: "tab", + replace(state, tagInfo, content) { + const attrs = tagInfo.attrs; + const name = attrs.name || attrs._default || ""; + const selected = attrs.selected === "" || attrs.selected === "true"; + + const tokens = []; + state.md.block.parse(content, state.md, state.env, tokens); + + state.env.tabContent.push({ + selected, + name, + content: tokens, + startPos: state.tokens.length, + }); + + return true; + }, + }); + }); +} + +export function setup(helper) { + setupTabs(helper); +} diff --git a/app/assets/javascripts/discourse/app/instance-initializers/markdown-tabs.js b/app/assets/javascripts/discourse/app/instance-initializers/markdown-tabs.js new file mode 100644 index 00000000000..17ceb124e3a --- /dev/null +++ b/app/assets/javascripts/discourse/app/instance-initializers/markdown-tabs.js @@ -0,0 +1,50 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; + +function initializeMarkdownTabs(api) { + api.decorateCooked( + ($elem) => { + const tabs = $elem[0].querySelectorAll(".markdown-tabs"); + if (!tabs.length) { + return; + } + + tabs.forEach((tabContainer) => { + const tabButtons = tabContainer.querySelectorAll(".markdown-tab"); + const panels = tabContainer.querySelectorAll(".markdown-tab-panel"); + + tabButtons.forEach((tab) => { + tab.addEventListener("click", (e) => { + e.preventDefault(); + + if (tab.hasAttribute("data-selected")) { + return; + } + + const tabId = tab.getAttribute("data-tab-id"); + + // Remove selected state from all tabs and panels + tabButtons.forEach((t) => t.removeAttribute("data-selected")); + panels.forEach((p) => p.removeAttribute("data-selected")); + + // Set selected state for clicked tab and its panel + tab.setAttribute("data-selected", ""); + const panel = tabContainer.querySelector( + `.markdown-tab-panel[data-tab-id="${tabId}"]` + ); + if (panel) { + panel.setAttribute("data-selected", ""); + } + }); + }); + }); + }, + { id: "discourse-tabs" } + ); +} + +export default { + name: "discourse-tabs", + initialize() { + withPluginApi("0.8.7", initializeMarkdownTabs); + }, +}; diff --git a/app/assets/stylesheets/common/base/_index.scss b/app/assets/stylesheets/common/base/_index.scss index 148f54f4c93..8885c893744 100644 --- a/app/assets/stylesheets/common/base/_index.scss +++ b/app/assets/stylesheets/common/base/_index.scss @@ -33,6 +33,7 @@ @import "login"; @import "magnific-popup"; @import "menu-panel"; +@import "markdown-tabs"; @import "modal"; @import "new-user"; @import "not-found"; diff --git a/app/assets/stylesheets/common/base/markdown-tabs.scss b/app/assets/stylesheets/common/base/markdown-tabs.scss new file mode 100644 index 00000000000..73b8dfc4dfb --- /dev/null +++ b/app/assets/stylesheets/common/base/markdown-tabs.scss @@ -0,0 +1,50 @@ +.markdown-tabs { + margin: 1em 0; + + .markdown-tabs-wrapper { + display: flex; + gap: 0.2em; + border-bottom: 2px solid var(--primary-low); + padding: 0 0.2em; + } + + .markdown-tab { + margin-bottom: -2px; + + &[data-selected] { + a { + color: var(--tertiary); + font-weight: 500; + border-bottom: 2px solid var(--tertiary); + } + } + + &:hover:not([data-selected]) { + a { + color: var(--primary); + background: var(--primary-very-low); + } + } + + a { + display: block; + padding: 0.5em 1em; + color: var(--primary-medium); + text-decoration: none; + cursor: pointer; + border-bottom: 2px solid transparent; + } + } + + .markdown-tab-panels { + padding: 0 0; + + .markdown-tab-panel { + display: none; + + &[data-selected] { + display: block; + } + } + } +} diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb index 96b505becc4..35612f759fa 100644 --- a/spec/lib/pretty_text_spec.rb +++ b/spec/lib/pretty_text_spec.rb @@ -2832,4 +2832,82 @@ HTML expect(doc.to_html).to eq(html_with_thumbnail) end end + + describe "markdown tabs" do + it "supports overriding default tab in markdown" do + md = <<~MD + [tabs] + [tab="First Tab"] + Tab 1 **content** + [/tab] + [tab="Second Tab" selected] + Tab 2 content + [/tab] + [/tabs] + MD + + html = PrettyText.cook(md) + expected = <<~HTML +
+ +
+
+

Tab 1 content

+
+
+

Tab 2 content

+
+
+
+ HTML + expect(html).to match_html(expected) + end + + it "supports tabs markup" do + md = <<~MD + [tabs] + [tab="First Tab"] + Tab 1 **content** + [/tab] + [tab="Second Tab"] + Tab 2 content + [/tab] + [/tabs] + MD + + html = PrettyText.cook(md) + expected = <<~HTML +
+ +
+
+

Tab 1 content

+
+
+

Tab 2 content

+
+
+
+ HTML + expect(html).to match_html(expected) + end + end end