diff --git a/client/src/components/ActivityBar/ActivityBar.vue b/client/src/components/ActivityBar/ActivityBar.vue index 0e2c54e7b181..3b9c1e2e0d14 100644 --- a/client/src/components/ActivityBar/ActivityBar.vue +++ b/client/src/components/ActivityBar/ActivityBar.vue @@ -9,6 +9,7 @@ import { useHashedUserId } from "@/composables/hashedUserId"; import { convertDropData } from "@/stores/activitySetup"; import { type Activity, useActivityStore } from "@/stores/activityStore"; import { useEventStore } from "@/stores/eventStore"; +import { useHelpModeStore } from "@/stores/helpmode/helpModeStore"; import { useUserStore } from "@/stores/userStore"; import VisualizationPanel from "../Panels/VisualizationPanel.vue"; @@ -18,6 +19,7 @@ import NotificationItem from "./Items/NotificationItem.vue"; import UploadItem from "./Items/UploadItem.vue"; import AdminPanel from "@/components/admin/AdminPanel.vue"; import FlexPanel from "@/components/Panels/FlexPanel.vue"; +import HelpModePanel from "@/components/Panels/HelpModePanel.vue"; import MultiviewPanel from "@/components/Panels/MultiviewPanel.vue"; import NotificationsPanel from "@/components/Panels/NotificationsPanel.vue"; import SettingsPanel from "@/components/Panels/SettingsPanel.vue"; @@ -39,6 +41,9 @@ const emit = defineEmits(["dragstart"]); // activities from store const { activities } = storeToRefs(activityStore); +// Galaxy help mode draggable status +const { draggableActive } = storeToRefs(useHelpModeStore()); + // drag references const dragTarget: Ref = ref(null); const dragItem: Ref = ref(null); @@ -198,6 +203,14 @@ watch( + + diff --git a/client/src/components/Form/FormDisplay.vue b/client/src/components/Form/FormDisplay.vue index e51dfe7e39b1..306ab5a5d5a6 100644 --- a/client/src/components/Form/FormDisplay.vue +++ b/client/src/components/Form/FormDisplay.vue @@ -16,8 +16,11 @@ + + + + diff --git a/client/src/components/Help/HelpModeText.vue b/client/src/components/Help/HelpModeText.vue new file mode 100644 index 000000000000..6ec40d10735c --- /dev/null +++ b/client/src/components/Help/HelpModeText.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/client/src/components/History/Modals/SelectorModal.vue b/client/src/components/History/Modals/SelectorModal.vue index 4c7be641955d..46a65243cf63 100644 --- a/client/src/components/History/Modals/SelectorModal.vue +++ b/client/src/components/History/Modals/SelectorModal.vue @@ -1,4 +1,6 @@ + + + + diff --git a/client/src/components/Masthead/Masthead.vue b/client/src/components/Masthead/Masthead.vue index 0390029c3951..04882a5ec394 100644 --- a/client/src/components/Masthead/Masthead.vue +++ b/client/src/components/Masthead/Masthead.vue @@ -10,6 +10,7 @@ import { useConfig } from "@/composables/config"; import { useUserStore } from "@/stores/userStore"; import { loadWebhookMenuItems } from "./_webhooks"; +import HelpModeSwitch from "./HelpModeSwitch"; import MastheadItem from "./MastheadItem"; import QuotaMeter from "./QuotaMeter"; import { getActiveTab } from "./utilities"; @@ -138,6 +139,9 @@ onMounted(() => { :active-tab="activeTab" @open-url="emit('open-url', $event)" /> + diff --git a/client/src/components/Panels/HelpModePanel.vue b/client/src/components/Panels/HelpModePanel.vue new file mode 100644 index 000000000000..ddd989e1fa88 --- /dev/null +++ b/client/src/components/Panels/HelpModePanel.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/client/src/composables/markdown.ts b/client/src/composables/markdown.ts index a6659f60d404..c9a6ae83a43d 100644 --- a/client/src/composables/markdown.ts +++ b/client/src/composables/markdown.ts @@ -30,6 +30,49 @@ function addRuleOpenLinksInNewPage(engine: MarkdownIt) { }; } +function addRulePrependInternalRouteToInternalLinks(engine: MarkdownIt, internalRoute: string) { + const defaultRender = + engine.renderer.rules.link_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + engine.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + + if (token) { + const hrefIndex = token.attrIndex("href"); + + if (hrefIndex >= 0) { + const href = token.attrs![hrefIndex]![1]; + if (href.startsWith("/")) { + token.attrs![hrefIndex]![1] = `${internalRoute}${href}`; + } + } + } + + return defaultRender(tokens, idx, options, env, self); + }; +} + +function addRuleRemoveBeforeFirstH1(engine: MarkdownIt) { + const defaultRender = engine.renderer.render; + + engine.renderer.render = function (tokens, options, env) { + let firstH1Index = tokens.findIndex((token) => token.type === "heading_open" && token.tag === "h1"); + + if (firstH1Index !== -1) { + // If there's a closing tag for the h1, we need to keep it + if (tokens[firstH1Index + 1]?.type === "heading_close") { + firstH1Index++; + } + tokens = tokens.slice(firstH1Index); + } + + return defaultRender.call(this, tokens, options, env); + }; +} + function addRuleHeadingIncreaseLevel(engine: MarkdownIt, increaseBy: number) { const defaultOpen = engine.renderer.rules.heading_open || @@ -69,9 +112,61 @@ function addRuleHeadingIncreaseLevel(engine: MarkdownIt, increaseBy: number) { }; } +/** Appends a horizontal rule to the end of each `
` element. */ +function appendHrRuleToDetails(doc: Document) { + const details = doc.querySelectorAll("details"); + details.forEach((detail) => { + // also, put a
inside, at the end of each open details tag + const hr = doc.createElement("hr"); + hr.classList.add("w-100"); + detail.appendChild(hr); + }); +} + +/** Replaces `` elements with elements for font-awesome icons. */ +function replaceCodesWithIcons(doc: Document) { + const codes = doc.querySelectorAll("code"); + codes.forEach((code) => { + const codeContent = code.innerHTML; + const iconRegex = /^fa-[a-z-]+$/; + if (iconRegex.test(codeContent)) { + const icon = document.createElement("i"); + icon.classList.add("fas", codeContent); + code.replaceWith(icon); + } + }); +} + +/** + * Adjusts the markdown output based on the options that do not have rules in + * `engine.renderer.rules`. + */ +function adjustMdForOptions(markdown: string, options: UseMarkdownOptions) { + if (!options.appendHrRuleToDetails && !options.replaceCodeWithIcon) { + return markdown; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(markdown, "text/html"); + if (options.appendHrRuleToDetails) { + appendHrRuleToDetails(doc); + } + if (options.replaceCodeWithIcon) { + replaceCodesWithIcons(doc); + } + const serializer = new XMLSerializer(); + const modifiedMarkdownOutput = serializer.serializeToString(doc); + return modifiedMarkdownOutput; +} + interface UseMarkdownOptions { openLinksInNewPage?: boolean; increaseHeadingLevelBy?: number; + removeContentBeforeFirstH1?: boolean; + html?: boolean; + appendHrRuleToDetails?: boolean; + replaceCodeWithIcon?: boolean; + internalRoute?: string; } type RawMarkdown = string; @@ -79,18 +174,28 @@ type HTMLString = string; /** Composable for rendering Markdown strings. */ export function useMarkdown(options: UseMarkdownOptions = {}) { - const mdEngine = MarkdownIt(); + const mdEngine = MarkdownIt({ html: options.html }); if (options.openLinksInNewPage) { addRuleOpenLinksInNewPage(mdEngine); } + if (options.removeContentBeforeFirstH1) { + addRuleRemoveBeforeFirstH1(mdEngine); + } + + if (options.internalRoute) { + addRulePrependInternalRouteToInternalLinks(mdEngine, options.internalRoute); + } + if (options.increaseHeadingLevelBy) { addRuleHeadingIncreaseLevel(mdEngine, options.increaseHeadingLevelBy); } function renderMarkdown(markdown: RawMarkdown): HTMLString { - return mdEngine.render(markdown); + let renderedMarkdown = mdEngine.render(markdown); + renderedMarkdown = adjustMdForOptions(renderedMarkdown, options); + return renderedMarkdown; } return { diff --git a/client/src/entry/analysis/modules/Analysis.vue b/client/src/entry/analysis/modules/Analysis.vue index df91d66c4655..28a5ad2284c6 100644 --- a/client/src/entry/analysis/modules/Analysis.vue +++ b/client/src/entry/analysis/modules/Analysis.vue @@ -1,11 +1,14 @@