diff --git a/.changeset/fifty-tips-listen.md b/.changeset/fifty-tips-listen.md new file mode 100644 index 00000000..245b7764 --- /dev/null +++ b/.changeset/fifty-tips-listen.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +Adds show/hide functionality to Snippets diff --git a/plugins/official/stack-snippets/src/schema.ts b/plugins/official/stack-snippets/src/schema.ts index 20c4dde4..ff403a0f 100644 --- a/plugins/official/stack-snippets/src/schema.ts +++ b/plugins/official/stack-snippets/src/schema.ts @@ -170,6 +170,7 @@ export const stackSnippetRichTextNodeSpec: { [name: string]: NodeSpec } = { babel: { default: "null" }, babelPresetReact: { default: "null" }, babelPresetTS: { default: "null" }, + showCode: { default: true }, }, }, stack_snippet_lang: { diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 319f1174..f27bdd2d 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -19,6 +19,10 @@ export class StackSnippetView implements NodeView { this.getPos = getPos; this.snippetMetadata = getSnippetMetadata(node); + const codeIsShown: boolean = + typeof node.attrs.showCode === "boolean" + ? node.attrs.showCode + : true; //We want to render the language blocks in the middle of some content, // so we need to custom-render stuff here ("holes" must be last) @@ -26,12 +30,53 @@ export class StackSnippetView implements NodeView { this.dom = snippetContainer; snippetContainer.className = "snippet"; + let toggleContainer: HTMLDivElement; + + if (this.snippetMetadata.hide === "true") { + // Create the show/hide link container + toggleContainer = document.createElement("div"); + toggleContainer.className = + "snippet-toggle-container d-inline-flex ai-center g2"; + + // Create the arrow span + const arrowSpan = document.createElement("span"); + arrowSpan.className = codeIsShown + ? "svg-icon-bg iconArrowDownSm" + : "svg-icon-bg iconArrowRightSm"; + toggleContainer.appendChild(arrowSpan); + + // Create the show/hide link + const toggleLink = document.createElement("a"); + toggleLink.href = "#"; + toggleLink.className = "snippet-toggle fs-body1"; + toggleLink.textContent = codeIsShown + ? "Hide code snippet" + : "Show code snippet"; + toggleContainer.appendChild(toggleLink); + + snippetContainer.appendChild(toggleContainer); + } + //This is the div where we're going to render any language blocks const snippetCode = document.createElement("div"); snippetCode.className = "snippet-code"; + snippetCode.style.display = codeIsShown ? "" : "none"; snippetContainer.appendChild(snippetCode); this.contentDOM = snippetCode; + if (this.snippetMetadata.hide === "true") { + toggleContainer.addEventListener("click", (e) => { + e.preventDefault(); + const isVisible = snippetCode.style.display !== "none"; + this.view.dispatch( + this.view.state.tr.setNodeMarkup(this.getPos(), null, { + ...node.attrs, + showCode: !isVisible, + }) + ); + }); + } + //And this is where we stash our CTAs and results, which are statically rendered. const snippetResult = document.createElement("div"); snippetResult.className = "snippet-result"; @@ -122,6 +167,23 @@ export class StackSnippetView implements NodeView { JSON.stringify(this.snippetMetadata); this.snippetMetadata = updatedMeta; + if (this.snippetMetadata.hide === "true") { + // Update the visibility of the snippet-code div and toggle link + const snippetCode = this.contentDOM; + const toggleLink = this.dom.querySelector(".snippet-toggle"); + const arrowSpan = this.dom.querySelector(".svg-icon-bg"); + + const isVisible = node.attrs.showCode as boolean; + snippetCode.style.display = isVisible ? "" : "none"; + toggleLink.textContent = isVisible + ? "Hide code snippet" + : "Show code snippet"; + arrowSpan.className = isVisible + ? "svg-icon-bg iconArrowDownSm" + : "svg-icon-bg iconArrowRightSm"; + } + + // Update the result container if metadata has changed const content = this.contentNode; if (metaChanged && content) { //Clear the node @@ -134,7 +196,7 @@ export class StackSnippetView implements NodeView { "allow-forms allow-modals allow-scripts" ); if (content.nodeType === Node.DOCUMENT_NODE) { - const document = content; + const document = content as Document; iframe.srcdoc = document.documentElement.innerHTML; } this.resultContainer.appendChild(iframe); @@ -148,6 +210,6 @@ export class StackSnippetView implements NodeView { private contentNode: Node; private getPos: () => number; resultContainer: HTMLDivElement; - dom: Node; + dom: HTMLElement; contentDOM: HTMLElement; } diff --git a/plugins/official/stack-snippets/test/snippet-view.test.ts b/plugins/official/stack-snippets/test/snippet-view.test.ts index a2928e45..bf2e142a 100644 --- a/plugins/official/stack-snippets/test/snippet-view.test.ts +++ b/plugins/official/stack-snippets/test/snippet-view.test.ts @@ -14,19 +14,23 @@ describe("StackSnippetView", () => { { language: "js" }, schema.text("console.log('test');") ); - const validSnippet = schema.nodes.stack_snippet.createChecked( - { - id: "1234", - babel: "true", - babelPresetReact: "true", - babelPresetTS: "null", - console: "true", - hide: "false", - }, - langNode - ); + const validSnippet = (hide: string) => + schema.nodes.stack_snippet.createChecked( + { + id: "1234", + babel: "true", + babelPresetReact: "true", + babelPresetTS: "null", + console: "true", + hide: hide, + }, + langNode + ); - const buildView = (options?: StackSnippetOptions): EditorView => { + const buildView = ( + options?: StackSnippetOptions, + hide: string = "false" + ): EditorView => { const state = EditorState.create({ schema: schema, plugins: [stackSnippetPasteHandler], @@ -48,7 +52,7 @@ describe("StackSnippetView", () => { view.state.tr.replaceRangeWith( 0, view.state.doc.nodeSize - 2, - validSnippet + validSnippet(hide) ) ); @@ -128,4 +132,39 @@ describe("StackSnippetView", () => { const [resultDiv] = resultDoc.getElementsByTagName("div"); expect(resultDiv.textContent).toBe("test!"); }); + + it("should render show/hide link if hide attr is true", () => { + const view = buildView(undefined, "true"); + const toggleLink = view.dom.querySelectorAll(".snippet-toggle"); + + expect(toggleLink).toHaveLength(1); + expect(toggleLink[0].textContent).toBe("Hide code snippet"); + }); + + it("should not render show/hide link if hide attr is false", () => { + const view = buildView(undefined, "false"); + const toggleLink = view.dom.querySelectorAll(".snippet-toggle"); + + expect(toggleLink).toHaveLength(0); + }); + + it("should toggle visibility of code snippet when show/hide link is clicked", () => { + const view = buildView(undefined, "true"); + const toggleLink = view.dom.querySelector(".snippet-toggle"); + const snippetCode = view.dom.querySelector(".snippet-code"); + + // Initial state: Code is visible + expect((snippetCode).style.display).toBe(""); + expect(toggleLink.textContent).toBe("Hide code snippet"); + + // Click to hide + (toggleLink).click(); + expect((snippetCode).style.display).toBe("none"); + expect(toggleLink.textContent).toBe("Show code snippet"); + + // Click to show + (toggleLink).click(); + expect((snippetCode).style.display).toBe(""); + expect(toggleLink.textContent).toBe("Hide code snippet"); + }); }); diff --git a/src/styles/icons.css b/src/styles/icons.css index 589b1eb7..baea1457 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -13,6 +13,13 @@ .svg-icon-bg.iconEllipsisHorizontal { --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EllipsisHorizontal.svg"); } + +.svg-icon-bg.iconArrowDownSm { + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowDownSm.svg"); +} +.svg-icon-bg.iconArrowRightSm { + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowRightSm.svg"); +} .svg-icon-bg.iconSearchSm { width: 21px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/SearchSm.svg");