From bfe893081d31af4677d9625de513b447d68da1ae Mon Sep 17 00:00:00 2001 From: James Boyden Date: Mon, 12 May 2025 10:41:25 +0100 Subject: [PATCH 1/9] feat: snippet result controls --- config/webpack.prod.js | 2 +- plugins/official/index.css | 1 + plugins/official/stack-snippets/snippets.css | 22 ++ .../official/stack-snippets/src/commands.ts | 49 +-- plugins/official/stack-snippets/src/schema.ts | 2 + .../stack-snippets/src/snippet-view.ts | 329 ++++++++++++++---- .../stack-snippets/src/stackSnippetPlugin.ts | 6 +- .../stack-snippets/test/commands.test.ts | 10 +- site/site.css | 1 + src/styles/icons.css | 13 + 10 files changed, 342 insertions(+), 93 deletions(-) create mode 100644 plugins/official/index.css create mode 100644 plugins/official/stack-snippets/snippets.css diff --git a/config/webpack.prod.js b/config/webpack.prod.js index 7709a792..334a10b9 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -6,7 +6,7 @@ module.exports = (env, argv) => entry: { app: "./src/index.ts", // NOTE we also get a `styles.bundle.js`, ignore this - styles: "./src/styles/index.css", + styles: ["./src/styles/index.css", "./plugins/official/index.css"], }, mode: "production", // don't bundle highlight.js or its languages; we expect consumers to supply these themselves diff --git a/plugins/official/index.css b/plugins/official/index.css new file mode 100644 index 00000000..e6032edd --- /dev/null +++ b/plugins/official/index.css @@ -0,0 +1 @@ +@import "./stack-snippets/snippets.css"; diff --git a/plugins/official/stack-snippets/snippets.css b/plugins/official/stack-snippets/snippets.css new file mode 100644 index 00000000..a0e4ccbc --- /dev/null +++ b/plugins/official/stack-snippets/snippets.css @@ -0,0 +1,22 @@ +.snippet-fullscreen { + position: fixed; + left: 1%; + top: 1%; + z-index: 999999; + background-color: var(--white); + width: 100vw; + height: 100vh; +} + +.snippet-buttons { + margin-bottom: 0 !important; +} + +.snippet-fullscreen-controls { + display: flex; +} + +.snippet-result-buttons { + display: flex; + margin-bottom: 0 !important; +} diff --git a/plugins/official/stack-snippets/src/commands.ts b/plugins/official/stack-snippets/src/commands.ts index 7b4aa48a..6f156d1d 100644 --- a/plugins/official/stack-snippets/src/commands.ts +++ b/plugins/official/stack-snippets/src/commands.ts @@ -65,7 +65,7 @@ function buildUpdateDocumentCallback(view: EditorView) { }; } -export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { +export function openSnippetModalCommand(options?: StackSnippetOptions): MenuCommand { return (state, dispatch, view): boolean => { //If we have no means of opening a modal, reject immediately if (!options || options.openSnippetsModal == undefined) { @@ -96,28 +96,37 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { return true; } - const snippetMetadata = getSnippetMetadata(discoveredSnippets[0]); - const [js] = snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "js" - ); - const [css] = snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "css" - ); - const [html] = snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "html" - ); - - options.openSnippetsModal( - buildUpdateDocumentCallback(view), - snippetMetadata, - js?.content, - css?.content, - html?.content - ); + openSnippetModal(discoveredSnippets[0], view, options); return true; }; } +export function openSnippetModal(node: Node, view: EditorView, options?: StackSnippetOptions): void { + //If we have no means of opening a modal, reject immediately + if (!options || options.openSnippetsModal == undefined) { + return; + } + + const snippetMetadata = getSnippetMetadata(node); + const [js] = snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "js" + ); + const [css] = snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "css" + ); + const [html] = snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "html" + ); + + options.openSnippetsModal( + buildUpdateDocumentCallback(view), + snippetMetadata, + js?.content, + css?.content, + html?.content + ); +} + /** * Snippets are comprised of a container around customized codeblocks. Some of the default behaviour for key-binds makes them behave * very strangely. @@ -142,7 +151,7 @@ export const commandList = (opts?: StackSnippetOptions) => ({ "Mod-Enter": swallowSnippetCommand, "Shift-Enter": swallowSnippetCommand, "Mod-r": swallowSnippetCommand, - [OPEN_SNIPPET_SHORTCUT]: openSnippetModal(opts), + [OPEN_SNIPPET_SHORTCUT]: openSnippetModalCommand(opts), }); export const stackSnippetCommandShortcuts = (opts?: StackSnippetOptions) => diff --git a/plugins/official/stack-snippets/src/schema.ts b/plugins/official/stack-snippets/src/schema.ts index ff403a0f..1cae7ad7 100644 --- a/plugins/official/stack-snippets/src/schema.ts +++ b/plugins/official/stack-snippets/src/schema.ts @@ -171,6 +171,8 @@ export const stackSnippetRichTextNodeSpec: { [name: string]: NodeSpec } = { babelPresetReact: { default: "null" }, babelPresetTS: { default: "null" }, showCode: { default: true }, + showResult: { default: true }, + fullscreen: { default: false }, }, }, stack_snippet_lang: { diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index f27bdd2d..a0fdaee3 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -6,6 +6,7 @@ import { StackSnippetOptions, } from "./common"; import { error } from "../../../../src"; +import { openSnippetModal } from "./commands"; export class StackSnippetView implements NodeView { constructor( @@ -83,75 +84,35 @@ export class StackSnippetView implements NodeView { snippetContainer.appendChild(snippetResult); const ctas = document.createElement("div"); - ctas.className = "snippet-ctas"; + ctas.className = "snippet-ctas d-flex ai-center"; if (opts && opts.renderer) { - const runCodeButton = document.createElement("button"); - runCodeButton.type = "button"; - runCodeButton.className = "s-btn s-btn__filled"; - runCodeButton.title = "Run code snippet"; - runCodeButton.setAttribute("aria-label", "Run code snippet"); - // create the svg svg-icon-bg element - const runIcon = document.createElement("span"); - runIcon.className = "svg-icon-bg iconPlay"; - runCodeButton.append(runIcon); - const runText = document.createElement("span"); - runText.textContent = "Run code snippet"; - runCodeButton.appendChild(runText); - runCodeButton.addEventListener("click", () => { - const [js] = this.snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "js" - ); - const [css] = this.snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "css" - ); - const [html] = this.snippetMetadata.langNodes.filter( - (l) => l.metaData.language == "html" - ); - this.opts - .renderer( - this.snippetMetadata, - js?.content, - css?.content, - html?.content - ) - .then((r) => { - if (r) { - this.contentNode = r; - //Trigger an update on the ProseMirror node - this.view.dispatch( - this.view.state.tr.setNodeMarkup( - this.getPos(), - null, - { - ...node.attrs, - content: this.snippetMetadata, - } - ) - ); - } else { - error( - "StackSnippetView - Run Code", - "No content to be displayed" - ); - } - }) - .catch((r) => { - error( - "StackSnippetView - Run Code", - "Error rendering snippet - %O", - r - ); - }); - }); + const snippetButtonContainer = document.createElement("div"); + snippetButtonContainer.className = "snippet-buttons gs4"; + ctas.appendChild(snippetButtonContainer); + this.buildRunButton(node, snippetButtonContainer); + this.buildEditButton(node, snippetButtonContainer); - ctas.appendChild(runCodeButton); + const snippetResultButtonContainer = document.createElement("div"); + snippetResultButtonContainer.className = "snippet-result-buttons d-none ml-auto gs4"; + ctas.appendChild(snippetResultButtonContainer); + this.showButton = this.buildShowButton(node, snippetResultButtonContainer); + this.hideButton = this.buildHideButton(node, snippetResultButtonContainer); + this.buildFullscreenExpandButton(node, snippetResultButtonContainer); + this.snippetResultButtonContainer = snippetResultButtonContainer; } snippetResult.appendChild(ctas); this.resultContainer = document.createElement("div"); this.resultContainer.className = "snippet-result-code"; - snippetResult.appendChild(this.resultContainer); + this.resultControlsContainer = document.createElement("div"); + this.resultControlsContainer.className = "snippet-result-controls d-none"; + this.fullscreenControls = document.createElement("div"); + this.fullscreenControls.className = "snippet-fullscreen-controls d-none"; + this.buildFullscreenCollapseButton(node, this.fullscreenControls); + this.resultControlsContainer.appendChild(this.fullscreenControls) + this.resultControlsContainer.appendChild(this.resultContainer); + snippetResult.appendChild(this.resultControlsContainer); //Rendered children will be handled by Prosemirror, but we want a handle on their content: this.snippetMetadata = getSnippetMetadata(node); @@ -185,7 +146,49 @@ export class StackSnippetView implements NodeView { // Update the result container if metadata has changed const content = this.contentNode; + + //Show the results, if the node meta allows it + if(content && node.attrs.showResult){ + if(this.resultControlsContainer.classList.contains("d-none")){ + this.resultControlsContainer.classList.remove("d-none") + } + if(!this.showButton.classList.contains("d-none")){ + this.showButton.classList.add("d-none"); + } + if(this.hideButton.classList.contains("d-none")){ + this.hideButton.classList.remove("d-none"); + } + } else { + if(!this.resultControlsContainer.classList.contains("d-none")){ + this.resultControlsContainer.classList.add("d-none"); + } + if(this.showButton.classList.contains("d-none")){ + this.showButton.classList.remove("d-none"); + } + if(!this.hideButton.classList.contains("d-none")){ + this.hideButton.classList.add("d-none"); + } + } + + //Fullscreen the results, if the node meta needs it + if(content && node.attrs.fullscreen){ + if(!this.resultControlsContainer.classList.contains("snippet-fullscreen")){ + this.resultControlsContainer.classList.add("snippet-fullscreen"); + } + if(this.fullscreenControls.classList.contains("d-none")){ + this.fullscreenControls.classList.remove("d-none"); + } + } else { + if(this.resultControlsContainer.classList.contains("snippet-fullscreen")){ + this.resultControlsContainer.classList.remove("snippet-fullscreen"); + } + if(!this.fullscreenControls.classList.contains("d-none")){ + this.fullscreenControls.classList.add("d-none"); + } + } + if (metaChanged && content) { + this.snippetResultButtonContainer.classList.remove("d-none"); //Clear the node this.resultContainer.innerHTML = ""; const iframe = document.createElement("iframe"); @@ -204,12 +207,210 @@ export class StackSnippetView implements NodeView { return true; } - private opts: StackSnippetOptions; - private view: EditorView; + private readonly opts: StackSnippetOptions; + private readonly view: EditorView; + private readonly getPos: () => number; private snippetMetadata: SnippetMetadata; private contentNode: Node; - private getPos: () => number; + private snippetResultButtonContainer: HTMLDivElement; + private showButton: HTMLButtonElement; + private hideButton: HTMLButtonElement; + private resultControlsContainer: HTMLDivElement; + private fullscreenControls: HTMLDivElement; resultContainer: HTMLDivElement; dom: HTMLElement; contentDOM: HTMLElement; + + private buildRunButton(node: ProseMirrorNode, container: HTMLDivElement): void { + const runCodeButton = document.createElement("button"); + runCodeButton.type = "button"; + runCodeButton.className = "s-btn s-btn__filled flex--item"; + runCodeButton.title = "Run code snippet"; + runCodeButton.setAttribute("aria-label", "Run code snippet"); + // create the svg svg-icon-bg element + const runIcon = document.createElement("span"); + runIcon.className = "svg-icon-bg iconPlay"; + runCodeButton.append(runIcon); + const runText = document.createElement("span"); + runText.textContent = "Run code snippet"; + runCodeButton.appendChild(runText); + runCodeButton.addEventListener("click", () => { + const [js] = this.snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "js" + ); + const [css] = this.snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "css" + ); + const [html] = this.snippetMetadata.langNodes.filter( + (l) => l.metaData.language == "html" + ); + this.opts + .renderer( + this.snippetMetadata, + js?.content, + css?.content, + html?.content + ) + .then((r) => { + if (r) { + this.contentNode = r; + //Trigger an update on the ProseMirror node + this.view.dispatch( + this.view.state.tr.setNodeMarkup( + this.getPos(), + null, + { + ...node.attrs, + content: this.snippetMetadata, + } + ) + ); + } else { + error( + "StackSnippetView - Run Code", + "No content to be displayed" + ); + } + }) + .catch((r) => { + error( + "StackSnippetView - Run Code", + "Error rendering snippet - %O", + r + ); + }); + }); + + container.appendChild(runCodeButton); + } + + private buildEditButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "s-btn s-btn__outlined flex--item"; + editButton.title = "Edit code snippet"; + editButton.setAttribute("aria-label", "Edit code snippet"); + editButton.textContent = "Edit code snippet"; + editButton.addEventListener('click', () => { + openSnippetModal(node, this.view, this.opts); + }); + + container.appendChild(editButton); + return editButton; + } + + private buildHideButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + const hideButton = document.createElement("button"); + hideButton.type = "button"; + hideButton.className = "s-btn flex--item"; + hideButton.title = "Hide results"; + hideButton.setAttribute("aria-label", "Hide results"); + const hideIcon = document.createElement("span"); + hideIcon.className = "svg-icon-bg iconEyeOff"; + hideButton.append(hideIcon); + const hideText = document.createElement("span"); + hideText.textContent = "Hide results"; + hideButton.appendChild(hideText); + hideButton.addEventListener('click', () => { + //Trigger an update on the ProseMirror node + this.view.dispatch( + this.view.state.tr.setNodeMarkup( + this.getPos(), + null, + { + ...node.attrs, + showResult: false + } + ) + ); + }) + + container.appendChild(hideButton); + return hideButton; + } + + private buildShowButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + const showButton = document.createElement("button"); + showButton.type = "button"; + showButton.className = "s-btn flex--item d-none"; + showButton.title = "Show results"; + showButton.setAttribute("aria-label", "Show results"); + const hideIcon = document.createElement("span"); + hideIcon.className = "svg-icon-bg iconEye"; + showButton.append(hideIcon); + const showText = document.createElement("span"); + showText.textContent = "Show results"; + showButton.appendChild(showText); + showButton.addEventListener('click', () => { + //Trigger an update on the ProseMirror node + this.view.dispatch( + this.view.state.tr.setNodeMarkup( + this.getPos(), + null, + { + ...node.attrs, + showResult: true + } + ) + ); + }) + + container.appendChild(showButton); + + return showButton; + } + + private buildFullscreenExpandButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + const expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = "s-btn flex--item"; + expandButton.title = "Expand Snippet"; + expandButton.setAttribute("aria-label", "Expand Snippet"); + expandButton.addEventListener('click', () => { + //Trigger an update on the ProseMirror node + this.view.dispatch( + this.view.state.tr.setNodeMarkup( + this.getPos(), + null, + { + ...node.attrs, + fullscreen: true + } + ) + ); + }) + const expandIcon = document.createElement("span"); + expandIcon.className = "svg-icon-bg iconShareSm"; + expandButton.append(expandIcon); + const expandText = document.createElement("span"); + expandText.textContent = "Expand Snippet"; + expandButton.appendChild(expandText); + + container.appendChild(expandButton); + return expandButton; + } + + private buildFullscreenCollapseButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + const collapseButton = document.createElement("button"); + collapseButton.type = "button"; + collapseButton.className = "s-btn flex--item td-underline ml-auto"; + collapseButton.title = "Close Snippet"; + collapseButton.textContent = "Close"; + collapseButton.setAttribute("aria-label", "Close Snippet"); + collapseButton.addEventListener('click', () => { + //Trigger an update on the ProseMirror node + this.view.dispatch( + this.view.state.tr.setNodeMarkup( + this.getPos(), + null, + { + ...node.attrs, + fullscreen: false + } + ) + ); + }) + container.appendChild(collapseButton); + return collapseButton; + } } diff --git a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts index 4bcf0da8..594a9987 100644 --- a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts +++ b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts @@ -11,7 +11,7 @@ import { EditorView } from "prosemirror-view"; import { StackSnippetView } from "./snippet-view"; import { StackSnippetOptions } from "./common"; import { stackSnippetPasteHandler } from "./paste-handler"; -import { openSnippetModal, stackSnippetCommandShortcuts } from "./commands"; +import { openSnippetModalCommand, stackSnippetCommandShortcuts } from "./commands"; /** * Build the StackSnippet plugin using hoisted options that can be specified at runtime @@ -63,10 +63,10 @@ export const stackSnippetPlugin: (opts?: StackSnippetOptions) => EditorPlugin = { key: "openSnippetModal", richText: { - command: openSnippetModal(opts), + command: openSnippetModalCommand(opts), }, commonmark: { - command: openSnippetModal(opts), + command: openSnippetModalCommand(opts), }, display: makeMenuButton( "StackSnippets", diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index aaf49d00..f4742674 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -1,7 +1,7 @@ import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import { SnippetMetadata, StackSnippetOptions } from "../src/common"; -import { openSnippetModal } from "../src/commands"; +import { openSnippetModalCommand } from "../src/commands"; import { RichTextHelpers } from "../../../../test"; import { buildSnippetSchema, @@ -49,7 +49,7 @@ describe("commands", () => { captureHtml = html; }, }; - const ret = openSnippetModal(snippetOptions)(state, () => {}); + const ret = openSnippetModalCommand(snippetOptions)(state, () => {}); //The openModal command is always handled when called with dispatch expect(ret).toBe(true); @@ -73,7 +73,7 @@ describe("commands", () => { [] ); - const command = openSnippetModal(snippetOptions); + const command = openSnippetModalCommand(snippetOptions); const ret = command(state, null); @@ -166,7 +166,7 @@ describe("commands", () => { captureCallback = updateDocumentCallback; }, }; - openSnippetModal(snippetOptions)( + openSnippetModalCommand(snippetOptions)( view.editorView.state, () => {}, view.editorView @@ -206,7 +206,7 @@ describe("commands", () => { captureCallback = updateDocumentCallback; }, }; - openSnippetModal(snippetOptions)( + openSnippetModalCommand(snippetOptions)( view.editorView.state, () => {}, view.editorView diff --git a/site/site.css b/site/site.css index e1ccb7c0..1b496df4 100644 --- a/site/site.css +++ b/site/site.css @@ -1,5 +1,6 @@ @import "~@stackoverflow/stacks/dist/css/stacks.css"; @import "../src/styles/index.css"; +@import "../plugins/official/index.css"; /* Place demo specific styles here */ body.themed.theme-custom, diff --git a/src/styles/icons.css b/src/styles/icons.css index c9b69c21..52c85ca3 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -27,3 +27,16 @@ .svg-icon-bg.iconStackSnippets { --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/StackSnippets.svg"); } + +.svg-icon-bg.iconShareSm { + width: 18px; + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ShareSm.svg"); +} + +.svg-icon-bg.iconEyeOff { + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EyeOff.svg"); +} + +.svg-icon-bg.iconEye { + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/Eye.svg"); +} From c2be73e6ea2e905b31170c4bff064214c50e42fb Mon Sep 17 00:00:00 2001 From: James Boyden Date: Mon, 12 May 2025 10:58:33 +0100 Subject: [PATCH 2/9] Linting, changset. --- .changeset/good-sides-make.md | 5 + .../official/stack-snippets/src/commands.ts | 10 +- .../stack-snippets/src/snippet-view.ts | 164 ++++++++++-------- .../stack-snippets/src/stackSnippetPlugin.ts | 5 +- 4 files changed, 112 insertions(+), 72 deletions(-) create mode 100644 .changeset/good-sides-make.md diff --git a/.changeset/good-sides-make.md b/.changeset/good-sides-make.md new file mode 100644 index 00000000..b951217c --- /dev/null +++ b/.changeset/good-sides-make.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +Add extra controls to Snippet View (Edit, Show/Hide results, Expand/Shrink results) diff --git a/plugins/official/stack-snippets/src/commands.ts b/plugins/official/stack-snippets/src/commands.ts index 6f156d1d..1ab57696 100644 --- a/plugins/official/stack-snippets/src/commands.ts +++ b/plugins/official/stack-snippets/src/commands.ts @@ -65,7 +65,9 @@ function buildUpdateDocumentCallback(view: EditorView) { }; } -export function openSnippetModalCommand(options?: StackSnippetOptions): MenuCommand { +export function openSnippetModalCommand( + options?: StackSnippetOptions +): MenuCommand { return (state, dispatch, view): boolean => { //If we have no means of opening a modal, reject immediately if (!options || options.openSnippetsModal == undefined) { @@ -101,7 +103,11 @@ export function openSnippetModalCommand(options?: StackSnippetOptions): MenuComm }; } -export function openSnippetModal(node: Node, view: EditorView, options?: StackSnippetOptions): void { +export function openSnippetModal( + node: Node, + view: EditorView, + options?: StackSnippetOptions +): void { //If we have no means of opening a modal, reject immediately if (!options || options.openSnippetsModal == undefined) { return; diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index a0fdaee3..ae7e2c27 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -93,11 +93,21 @@ export class StackSnippetView implements NodeView { this.buildEditButton(node, snippetButtonContainer); const snippetResultButtonContainer = document.createElement("div"); - snippetResultButtonContainer.className = "snippet-result-buttons d-none ml-auto gs4"; + snippetResultButtonContainer.className = + "snippet-result-buttons d-none ml-auto gs4"; ctas.appendChild(snippetResultButtonContainer); - this.showButton = this.buildShowButton(node, snippetResultButtonContainer); - this.hideButton = this.buildHideButton(node, snippetResultButtonContainer); - this.buildFullscreenExpandButton(node, snippetResultButtonContainer); + this.showButton = this.buildShowButton( + node, + snippetResultButtonContainer + ); + this.hideButton = this.buildHideButton( + node, + snippetResultButtonContainer + ); + this.buildFullscreenExpandButton( + node, + snippetResultButtonContainer + ); this.snippetResultButtonContainer = snippetResultButtonContainer; } @@ -106,11 +116,13 @@ export class StackSnippetView implements NodeView { this.resultContainer = document.createElement("div"); this.resultContainer.className = "snippet-result-code"; this.resultControlsContainer = document.createElement("div"); - this.resultControlsContainer.className = "snippet-result-controls d-none"; + this.resultControlsContainer.className = + "snippet-result-controls d-none"; this.fullscreenControls = document.createElement("div"); - this.fullscreenControls.className = "snippet-fullscreen-controls d-none"; + this.fullscreenControls.className = + "snippet-fullscreen-controls d-none"; this.buildFullscreenCollapseButton(node, this.fullscreenControls); - this.resultControlsContainer.appendChild(this.fullscreenControls) + this.resultControlsContainer.appendChild(this.fullscreenControls); this.resultControlsContainer.appendChild(this.resultContainer); snippetResult.appendChild(this.resultControlsContainer); @@ -148,41 +160,53 @@ export class StackSnippetView implements NodeView { const content = this.contentNode; //Show the results, if the node meta allows it - if(content && node.attrs.showResult){ - if(this.resultControlsContainer.classList.contains("d-none")){ - this.resultControlsContainer.classList.remove("d-none") + if (content && node.attrs.showResult) { + if (this.resultControlsContainer.classList.contains("d-none")) { + this.resultControlsContainer.classList.remove("d-none"); } - if(!this.showButton.classList.contains("d-none")){ + if (!this.showButton.classList.contains("d-none")) { this.showButton.classList.add("d-none"); } - if(this.hideButton.classList.contains("d-none")){ + if (this.hideButton.classList.contains("d-none")) { this.hideButton.classList.remove("d-none"); } } else { - if(!this.resultControlsContainer.classList.contains("d-none")){ + if (!this.resultControlsContainer.classList.contains("d-none")) { this.resultControlsContainer.classList.add("d-none"); } - if(this.showButton.classList.contains("d-none")){ + if (this.showButton.classList.contains("d-none")) { this.showButton.classList.remove("d-none"); } - if(!this.hideButton.classList.contains("d-none")){ + if (!this.hideButton.classList.contains("d-none")) { this.hideButton.classList.add("d-none"); } } //Fullscreen the results, if the node meta needs it - if(content && node.attrs.fullscreen){ - if(!this.resultControlsContainer.classList.contains("snippet-fullscreen")){ - this.resultControlsContainer.classList.add("snippet-fullscreen"); + if (content && node.attrs.fullscreen) { + if ( + !this.resultControlsContainer.classList.contains( + "snippet-fullscreen" + ) + ) { + this.resultControlsContainer.classList.add( + "snippet-fullscreen" + ); } - if(this.fullscreenControls.classList.contains("d-none")){ + if (this.fullscreenControls.classList.contains("d-none")) { this.fullscreenControls.classList.remove("d-none"); } } else { - if(this.resultControlsContainer.classList.contains("snippet-fullscreen")){ - this.resultControlsContainer.classList.remove("snippet-fullscreen"); + if ( + this.resultControlsContainer.classList.contains( + "snippet-fullscreen" + ) + ) { + this.resultControlsContainer.classList.remove( + "snippet-fullscreen" + ); } - if(!this.fullscreenControls.classList.contains("d-none")){ + if (!this.fullscreenControls.classList.contains("d-none")) { this.fullscreenControls.classList.add("d-none"); } } @@ -221,7 +245,10 @@ export class StackSnippetView implements NodeView { dom: HTMLElement; contentDOM: HTMLElement; - private buildRunButton(node: ProseMirrorNode, container: HTMLDivElement): void { + private buildRunButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): void { const runCodeButton = document.createElement("button"); runCodeButton.type = "button"; runCodeButton.className = "s-btn s-btn__filled flex--item"; @@ -284,14 +311,17 @@ export class StackSnippetView implements NodeView { container.appendChild(runCodeButton); } - private buildEditButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + private buildEditButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): HTMLButtonElement { const editButton = document.createElement("button"); editButton.type = "button"; editButton.className = "s-btn s-btn__outlined flex--item"; editButton.title = "Edit code snippet"; editButton.setAttribute("aria-label", "Edit code snippet"); editButton.textContent = "Edit code snippet"; - editButton.addEventListener('click', () => { + editButton.addEventListener("click", () => { openSnippetModal(node, this.view, this.opts); }); @@ -299,7 +329,10 @@ export class StackSnippetView implements NodeView { return editButton; } - private buildHideButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + private buildHideButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): HTMLButtonElement { const hideButton = document.createElement("button"); hideButton.type = "button"; hideButton.className = "s-btn flex--item"; @@ -311,25 +344,24 @@ export class StackSnippetView implements NodeView { const hideText = document.createElement("span"); hideText.textContent = "Hide results"; hideButton.appendChild(hideText); - hideButton.addEventListener('click', () => { + hideButton.addEventListener("click", () => { //Trigger an update on the ProseMirror node this.view.dispatch( - this.view.state.tr.setNodeMarkup( - this.getPos(), - null, - { - ...node.attrs, - showResult: false - } - ) + this.view.state.tr.setNodeMarkup(this.getPos(), null, { + ...node.attrs, + showResult: false, + }) ); - }) + }); container.appendChild(hideButton); return hideButton; } - private buildShowButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + private buildShowButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): HTMLButtonElement { const showButton = document.createElement("button"); showButton.type = "button"; showButton.className = "s-btn flex--item d-none"; @@ -341,44 +373,39 @@ export class StackSnippetView implements NodeView { const showText = document.createElement("span"); showText.textContent = "Show results"; showButton.appendChild(showText); - showButton.addEventListener('click', () => { + showButton.addEventListener("click", () => { //Trigger an update on the ProseMirror node this.view.dispatch( - this.view.state.tr.setNodeMarkup( - this.getPos(), - null, - { - ...node.attrs, - showResult: true - } - ) + this.view.state.tr.setNodeMarkup(this.getPos(), null, { + ...node.attrs, + showResult: true, + }) ); - }) + }); container.appendChild(showButton); return showButton; } - private buildFullscreenExpandButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + private buildFullscreenExpandButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): HTMLButtonElement { const expandButton = document.createElement("button"); expandButton.type = "button"; expandButton.className = "s-btn flex--item"; expandButton.title = "Expand Snippet"; expandButton.setAttribute("aria-label", "Expand Snippet"); - expandButton.addEventListener('click', () => { + expandButton.addEventListener("click", () => { //Trigger an update on the ProseMirror node this.view.dispatch( - this.view.state.tr.setNodeMarkup( - this.getPos(), - null, - { - ...node.attrs, - fullscreen: true - } - ) + this.view.state.tr.setNodeMarkup(this.getPos(), null, { + ...node.attrs, + fullscreen: true, + }) ); - }) + }); const expandIcon = document.createElement("span"); expandIcon.className = "svg-icon-bg iconShareSm"; expandButton.append(expandIcon); @@ -390,26 +417,25 @@ export class StackSnippetView implements NodeView { return expandButton; } - private buildFullscreenCollapseButton(node: ProseMirrorNode, container: HTMLDivElement): HTMLButtonElement { + private buildFullscreenCollapseButton( + node: ProseMirrorNode, + container: HTMLDivElement + ): HTMLButtonElement { const collapseButton = document.createElement("button"); collapseButton.type = "button"; collapseButton.className = "s-btn flex--item td-underline ml-auto"; collapseButton.title = "Close Snippet"; collapseButton.textContent = "Close"; collapseButton.setAttribute("aria-label", "Close Snippet"); - collapseButton.addEventListener('click', () => { + collapseButton.addEventListener("click", () => { //Trigger an update on the ProseMirror node this.view.dispatch( - this.view.state.tr.setNodeMarkup( - this.getPos(), - null, - { - ...node.attrs, - fullscreen: false - } - ) + this.view.state.tr.setNodeMarkup(this.getPos(), null, { + ...node.attrs, + fullscreen: false, + }) ); - }) + }); container.appendChild(collapseButton); return collapseButton; } diff --git a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts index 594a9987..bf56304b 100644 --- a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts +++ b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts @@ -11,7 +11,10 @@ import { EditorView } from "prosemirror-view"; import { StackSnippetView } from "./snippet-view"; import { StackSnippetOptions } from "./common"; import { stackSnippetPasteHandler } from "./paste-handler"; -import { openSnippetModalCommand, stackSnippetCommandShortcuts } from "./commands"; +import { + openSnippetModalCommand, + stackSnippetCommandShortcuts, +} from "./commands"; /** * Build the StackSnippet plugin using hoisted options that can be specified at runtime From 8477f305eb90abc0e73c60ef371e2268822c7efd Mon Sep 17 00:00:00 2001 From: James Boyden Date: Mon, 12 May 2025 11:29:03 +0100 Subject: [PATCH 3/9] Amend control tests to account for changing dom/edit button --- .../stack-snippets/src/snippet-view.ts | 7 +++- .../stack-snippets/test/render.test.ts | 18 ++++++--- .../stack-snippets/test/snippet-view.test.ts | 37 ++++++++++++------- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index ae7e2c27..9af0002b 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -148,6 +148,11 @@ export class StackSnippetView implements NodeView { const isVisible = node.attrs.showCode as boolean; snippetCode.style.display = isVisible ? "" : "none"; + if(isVisible && snippetCode.classList.contains("d-none")) { + snippetCode.classList.remove("d-none"); + } else { + snippetCode.classList.add("d-none"); + } toggleLink.textContent = isVisible ? "Hide code snippet" : "Show code snippet"; @@ -170,7 +175,7 @@ export class StackSnippetView implements NodeView { if (this.hideButton.classList.contains("d-none")) { this.hideButton.classList.remove("d-none"); } - } else { + } else if (content && !node.attrs.showResult) { if (!this.resultControlsContainer.classList.contains("d-none")) { this.resultControlsContainer.classList.add("d-none"); } diff --git a/plugins/official/stack-snippets/test/render.test.ts b/plugins/official/stack-snippets/test/render.test.ts index b5148ecd..9756b405 100644 --- a/plugins/official/stack-snippets/test/render.test.ts +++ b/plugins/official/stack-snippets/test/render.test.ts @@ -26,15 +26,21 @@ describe("stack snippets", () => { expect(resultCode[0].childNodes).toHaveLength(0); }; - const shouldHaveRunCodeButton = (rendered: Element) => { - const runCode = rendered.querySelectorAll("div.snippet-ctas > button"); - expect(runCode).toHaveLength(1); - expect(runCode[0].attributes.getNamedItem("title").value).toBe( + const shouldHaveSnippetControls = (rendered: Element) => { + const snippetButtons = rendered.querySelectorAll("div.snippet-buttons > button"); + expect(snippetButtons).toHaveLength(2); + expect(snippetButtons[0].attributes.getNamedItem("title").value).toBe( "Run code snippet" ); - expect(runCode[0].attributes.getNamedItem("aria-label").value).toBe( + expect(snippetButtons[0].attributes.getNamedItem("aria-label").value).toBe( "Run code snippet" ); + expect(snippetButtons[1].attributes.getNamedItem("title").value).toBe( + "Edit code snippet" + ); + expect(snippetButtons[1].attributes.getNamedItem("aria-label").value).toBe( + "Edit code snippet" + ); }; const shouldHaveLanguageBlocks = (rendered: Element, langs: string[]) => { @@ -60,7 +66,7 @@ describe("stack snippets", () => { const rendered = richEditorView.dom; shouldHaveSnippetBlock(rendered); - shouldHaveRunCodeButton(rendered); + shouldHaveSnippetControls(rendered); shouldHaveLanguageBlocks(rendered, langs); } ); diff --git a/plugins/official/stack-snippets/test/snippet-view.test.ts b/plugins/official/stack-snippets/test/snippet-view.test.ts index bf2e142a..007932ee 100644 --- a/plugins/official/stack-snippets/test/snippet-view.test.ts +++ b/plugins/official/stack-snippets/test/snippet-view.test.ts @@ -59,29 +59,38 @@ describe("StackSnippetView", () => { return view; }; - it("should render run code button if renderer provided", () => { + const findSnippetControls = (rendered: Element): NodeListOf => + rendered.querySelectorAll("div.snippet-buttons > button"); + + it("should render snippet buttons if renderer provided", () => { const view = buildView({ renderer: () => Promise.resolve(null), openSnippetsModal: () => {}, }); - const runCodeButton = view.dom.querySelectorAll( - ".snippet-ctas > button.s-btn" - ); - expect(runCodeButton).toHaveLength(1); - expect(runCodeButton[0].getAttribute("aria-label")).toBe( + const snippetButtons = findSnippetControls(view.dom); + + expect(snippetButtons).toHaveLength(2); + expect(snippetButtons[0].attributes.getNamedItem("title").value).toBe( + "Run code snippet" + ); + expect(snippetButtons[0].attributes.getNamedItem("aria-label").value).toBe( "Run code snippet" ); - expect(runCodeButton[0].getAttribute("title")).toBe("Run code snippet"); + expect(snippetButtons[1].attributes.getNamedItem("title").value).toBe( + "Edit code snippet" + ); + expect(snippetButtons[1].attributes.getNamedItem("aria-label").value).toBe( + "Edit code snippet" + ); }); - it("should not render run code button if no renderer provided", () => { + it("should not render snippet buttons if no renderer provided", () => { const view = buildView(); - const runCodeButton = view.dom.querySelectorAll( - ".snippet-ctas > button.s-btn" - ); - expect(runCodeButton).toHaveLength(0); + const snippetBUttons = findSnippetControls(view.dom) + + expect(snippetBUttons).toHaveLength(0); }); it("should call renderer when button clicked", () => { @@ -94,7 +103,7 @@ describe("StackSnippetView", () => { openSnippetsModal: () => {}, }); const [runCodeButton] = view.dom.querySelectorAll( - ".snippet-ctas > button.s-btn" + ".snippet-buttons > button.s-btn" ); (runCodeButton).click(); @@ -115,7 +124,7 @@ describe("StackSnippetView", () => { openSnippetsModal: () => {}, }); const [runCodeButton] = view.dom.querySelectorAll( - ".snippet-ctas > button.s-btn" + ".snippet-buttons > button.s-btn" ); (runCodeButton).click(); From d7a9a60696651dcb7873d8f6502bedc1d0594043 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Mon, 12 May 2025 11:57:45 +0100 Subject: [PATCH 4/9] Add extra test for edit code callback --- .../stack-snippets/test/snippet-view.test.ts | 168 ++++++++++-------- 1 file changed, 98 insertions(+), 70 deletions(-) diff --git a/plugins/official/stack-snippets/test/snippet-view.test.ts b/plugins/official/stack-snippets/test/snippet-view.test.ts index 007932ee..9d311b91 100644 --- a/plugins/official/stack-snippets/test/snippet-view.test.ts +++ b/plugins/official/stack-snippets/test/snippet-view.test.ts @@ -88,92 +88,120 @@ describe("StackSnippetView", () => { it("should not render snippet buttons if no renderer provided", () => { const view = buildView(); - const snippetBUttons = findSnippetControls(view.dom) + const snippetButtons = findSnippetControls(view.dom) - expect(snippetBUttons).toHaveLength(0); + expect(snippetButtons).toHaveLength(0); }); - it("should call renderer when button clicked", () => { - let buttonClicked = false; - const view = buildView({ - renderer: () => { - buttonClicked = true; - return Promise.resolve(null); - }, - openSnippetsModal: () => {}, - }); - const [runCodeButton] = view.dom.querySelectorAll( - ".snippet-buttons > button.s-btn" - ); - - (runCodeButton).click(); + describe("Run Code Snippet button", () => { + it("should call renderer when button clicked", () => { + let buttonClicked = false; + const view = buildView({ + renderer: () => { + buttonClicked = true; + return Promise.resolve(null); + }, + openSnippetsModal: () => {}, + }); + const [runCodeButton] = view.dom.querySelectorAll( + ".snippet-buttons > button.s-btn" + ); - expect(buttonClicked).toBe(true); - }); + (runCodeButton).click(); - it("should render returned content when button clicked", async () => { - const renderDoc = - document.implementation.createHTMLDocument("test doc"); - const testDiv = document.createElement("div"); - testDiv.textContent = "test!"; - renderDoc.body.appendChild(testDiv); - const view = buildView({ - renderer: () => { - return Promise.resolve(renderDoc); - }, - openSnippetsModal: () => {}, + expect(buttonClicked).toBe(true); }); - const [runCodeButton] = view.dom.querySelectorAll( - ".snippet-buttons > button.s-btn" - ); - (runCodeButton).click(); - // wait for the promise to resolve (immediately) and check that the async content was pulled in - await RichTextHelpers.sleepAsync(0); - - const [iframe] = view.dom.querySelectorAll(".snippet-box-result"); - //Testing the HTMLIFrameElement is a nightmare, instead we're going to grab it's srcdoc and ensure that it parses back to our document - const foundDoc = (iframe).srcdoc; - const resultDoc = document.implementation.createHTMLDocument(); - resultDoc.open(); - resultDoc.write(foundDoc); - resultDoc.close(); - const [resultDiv] = resultDoc.getElementsByTagName("div"); - expect(resultDiv.textContent).toBe("test!"); + it("should render returned content when button clicked", async () => { + const renderDoc = + document.implementation.createHTMLDocument("test doc"); + const testDiv = document.createElement("div"); + testDiv.textContent = "test!"; + renderDoc.body.appendChild(testDiv); + const view = buildView({ + renderer: () => { + return Promise.resolve(renderDoc); + }, + openSnippetsModal: () => {}, + }); + const [runCodeButton] = view.dom.querySelectorAll( + ".snippet-buttons > button.s-btn" + ); + + (runCodeButton).click(); + // wait for the promise to resolve (immediately) and check that the async content was pulled in + await RichTextHelpers.sleepAsync(0); + + const [iframe] = view.dom.querySelectorAll(".snippet-box-result"); + //Testing the HTMLIFrameElement is a nightmare, instead we're going to grab it's srcdoc and ensure that it parses back to our document + const foundDoc = (iframe).srcdoc; + const resultDoc = document.implementation.createHTMLDocument(); + resultDoc.open(); + resultDoc.write(foundDoc); + resultDoc.close(); + 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"); + describe("Edit Snippet", () => { + it("should call openSnippetModal when clicked", async () => { + let openCalled = false; + const view = buildView({ + renderer: () => { + return Promise.resolve(null); + }, + openSnippetsModal: () => { + openCalled = true; + }, + }); + const editCodeButton = view.dom.querySelectorAll( + ".snippet-buttons > button.s-btn" + )[1]; + //Double check we've grabbed the right button + expect(editCodeButton.textContent).toContain("Edit"); + + (editCodeButton).click(); + await RichTextHelpers.sleepAsync(0); - expect(toggleLink).toHaveLength(1); - expect(toggleLink[0].textContent).toBe("Hide code snippet"); + expect(openCalled).toBeTruthy(); + }); }); - it("should not render show/hide link if hide attr is false", () => { - const view = buildView(undefined, "false"); - const toggleLink = view.dom.querySelectorAll(".snippet-toggle"); + describe("Show/Hide Snippet", () => { + 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(0); - }); + 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"); - 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"); + 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"); + // 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 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"); + // Click to show + (toggleLink).click(); + expect((snippetCode).style.display).toBe(""); + expect(toggleLink.textContent).toBe("Hide code snippet"); + }); }); }); From 1a1873f13793891123d4fe9c951d2e2e7169d203 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Mon, 12 May 2025 11:58:54 +0100 Subject: [PATCH 5/9] linting --- .../official/stack-snippets/src/snippet-view.ts | 2 +- .../official/stack-snippets/test/render.test.ts | 16 +++++++++------- .../stack-snippets/test/snippet-view.test.ts | 14 +++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 9af0002b..6ce4e6d3 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -148,7 +148,7 @@ export class StackSnippetView implements NodeView { const isVisible = node.attrs.showCode as boolean; snippetCode.style.display = isVisible ? "" : "none"; - if(isVisible && snippetCode.classList.contains("d-none")) { + if (isVisible && snippetCode.classList.contains("d-none")) { snippetCode.classList.remove("d-none"); } else { snippetCode.classList.add("d-none"); diff --git a/plugins/official/stack-snippets/test/render.test.ts b/plugins/official/stack-snippets/test/render.test.ts index 9756b405..37d0a1fa 100644 --- a/plugins/official/stack-snippets/test/render.test.ts +++ b/plugins/official/stack-snippets/test/render.test.ts @@ -27,20 +27,22 @@ describe("stack snippets", () => { }; const shouldHaveSnippetControls = (rendered: Element) => { - const snippetButtons = rendered.querySelectorAll("div.snippet-buttons > button"); + const snippetButtons = rendered.querySelectorAll( + "div.snippet-buttons > button" + ); expect(snippetButtons).toHaveLength(2); expect(snippetButtons[0].attributes.getNamedItem("title").value).toBe( "Run code snippet" ); - expect(snippetButtons[0].attributes.getNamedItem("aria-label").value).toBe( - "Run code snippet" - ); + expect( + snippetButtons[0].attributes.getNamedItem("aria-label").value + ).toBe("Run code snippet"); expect(snippetButtons[1].attributes.getNamedItem("title").value).toBe( "Edit code snippet" ); - expect(snippetButtons[1].attributes.getNamedItem("aria-label").value).toBe( - "Edit code snippet" - ); + expect( + snippetButtons[1].attributes.getNamedItem("aria-label").value + ).toBe("Edit code snippet"); }; const shouldHaveLanguageBlocks = (rendered: Element, langs: string[]) => { diff --git a/plugins/official/stack-snippets/test/snippet-view.test.ts b/plugins/official/stack-snippets/test/snippet-view.test.ts index 9d311b91..ea183f17 100644 --- a/plugins/official/stack-snippets/test/snippet-view.test.ts +++ b/plugins/official/stack-snippets/test/snippet-view.test.ts @@ -74,21 +74,21 @@ describe("StackSnippetView", () => { expect(snippetButtons[0].attributes.getNamedItem("title").value).toBe( "Run code snippet" ); - expect(snippetButtons[0].attributes.getNamedItem("aria-label").value).toBe( - "Run code snippet" - ); + expect( + snippetButtons[0].attributes.getNamedItem("aria-label").value + ).toBe("Run code snippet"); expect(snippetButtons[1].attributes.getNamedItem("title").value).toBe( "Edit code snippet" ); - expect(snippetButtons[1].attributes.getNamedItem("aria-label").value).toBe( - "Edit code snippet" - ); + expect( + snippetButtons[1].attributes.getNamedItem("aria-label").value + ).toBe("Edit code snippet"); }); it("should not render snippet buttons if no renderer provided", () => { const view = buildView(); - const snippetButtons = findSnippetControls(view.dom) + const snippetButtons = findSnippetControls(view.dom); expect(snippetButtons).toHaveLength(0); }); From 3cfa118b3b049e5d0b3e62d00bae190401a37b27 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Tue, 13 May 2025 11:11:22 +0100 Subject: [PATCH 6/9] Move back to atomic styles, load atomic styles in based on class-marker --- config/webpack.prod.js | 2 +- plugins/official/index.css | 1 - plugins/official/stack-snippets/snippets.css | 22 --- .../stack-snippets/src/snippet-view.ts | 136 +++++++++--------- site/site.css | 1 - src/styles/icons.css | 1 + 6 files changed, 66 insertions(+), 97 deletions(-) delete mode 100644 plugins/official/index.css delete mode 100644 plugins/official/stack-snippets/snippets.css diff --git a/config/webpack.prod.js b/config/webpack.prod.js index 334a10b9..7709a792 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -6,7 +6,7 @@ module.exports = (env, argv) => entry: { app: "./src/index.ts", // NOTE we also get a `styles.bundle.js`, ignore this - styles: ["./src/styles/index.css", "./plugins/official/index.css"], + styles: "./src/styles/index.css", }, mode: "production", // don't bundle highlight.js or its languages; we expect consumers to supply these themselves diff --git a/plugins/official/index.css b/plugins/official/index.css deleted file mode 100644 index e6032edd..00000000 --- a/plugins/official/index.css +++ /dev/null @@ -1 +0,0 @@ -@import "./stack-snippets/snippets.css"; diff --git a/plugins/official/stack-snippets/snippets.css b/plugins/official/stack-snippets/snippets.css deleted file mode 100644 index a0e4ccbc..00000000 --- a/plugins/official/stack-snippets/snippets.css +++ /dev/null @@ -1,22 +0,0 @@ -.snippet-fullscreen { - position: fixed; - left: 1%; - top: 1%; - z-index: 999999; - background-color: var(--white); - width: 100vw; - height: 100vh; -} - -.snippet-buttons { - margin-bottom: 0 !important; -} - -.snippet-fullscreen-controls { - display: flex; -} - -.snippet-result-buttons { - display: flex; - margin-bottom: 0 !important; -} diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 6ce4e6d3..6ee8674f 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -18,6 +18,7 @@ export class StackSnippetView implements NodeView { this.opts = opts; this.view = view; this.getPos = getPos; + this.node = node; this.snippetMetadata = getSnippetMetadata(node); const codeIsShown: boolean = @@ -87,25 +88,25 @@ export class StackSnippetView implements NodeView { ctas.className = "snippet-ctas d-flex ai-center"; if (opts && opts.renderer) { const snippetButtonContainer = document.createElement("div"); - snippetButtonContainer.className = "snippet-buttons gs4"; + snippetButtonContainer.className = "snippet-buttons mb0 gs4"; ctas.appendChild(snippetButtonContainer); - this.buildRunButton(node, snippetButtonContainer); - this.buildEditButton(node, snippetButtonContainer); + this.buildRunButton(snippetButtonContainer); + this.buildEditButton(snippetButtonContainer); const snippetResultButtonContainer = document.createElement("div"); snippetResultButtonContainer.className = - "snippet-result-buttons d-none ml-auto gs4"; + "snippet-result-buttons d-flex mb0 d-none ml-auto gs4"; ctas.appendChild(snippetResultButtonContainer); this.showButton = this.buildShowButton( - node, snippetResultButtonContainer ); this.hideButton = this.buildHideButton( - node, snippetResultButtonContainer ); - this.buildFullscreenExpandButton( - node, + this.fullscreenButton = this.buildFullscreenExpandButton( + snippetResultButtonContainer + ); + this.fullscreenReturnButton = this.buildFullscreenCollapseButton( snippetResultButtonContainer ); this.snippetResultButtonContainer = snippetResultButtonContainer; @@ -115,16 +116,8 @@ export class StackSnippetView implements NodeView { this.resultContainer = document.createElement("div"); this.resultContainer.className = "snippet-result-code"; - this.resultControlsContainer = document.createElement("div"); - this.resultControlsContainer.className = - "snippet-result-controls d-none"; - this.fullscreenControls = document.createElement("div"); - this.fullscreenControls.className = - "snippet-fullscreen-controls d-none"; - this.buildFullscreenCollapseButton(node, this.fullscreenControls); - this.resultControlsContainer.appendChild(this.fullscreenControls); - this.resultControlsContainer.appendChild(this.resultContainer); - snippetResult.appendChild(this.resultControlsContainer); + + snippetResult.appendChild(this.resultContainer); //Rendered children will be handled by Prosemirror, but we want a handle on their content: this.snippetMetadata = getSnippetMetadata(node); @@ -132,11 +125,13 @@ export class StackSnippetView implements NodeView { update(node: ProseMirrorNode): boolean { if (node.type.name !== "stack_snippet") return false; + //Update the reference used by buttons, etc. for the most up-to-date node reference + this.node = node; //Check to see if the metadata has changed const updatedMeta = getSnippetMetadata(node); const metaChanged = - JSON.stringify(updatedMeta) === + JSON.stringify(updatedMeta) !== JSON.stringify(this.snippetMetadata); this.snippetMetadata = updatedMeta; @@ -166,57 +161,67 @@ export class StackSnippetView implements NodeView { //Show the results, if the node meta allows it if (content && node.attrs.showResult) { - if (this.resultControlsContainer.classList.contains("d-none")) { - this.resultControlsContainer.classList.remove("d-none"); - } if (!this.showButton.classList.contains("d-none")) { this.showButton.classList.add("d-none"); } if (this.hideButton.classList.contains("d-none")) { this.hideButton.classList.remove("d-none"); } - } else if (content && !node.attrs.showResult) { - if (!this.resultControlsContainer.classList.contains("d-none")) { - this.resultControlsContainer.classList.add("d-none"); + if (this.resultContainer.classList.contains("d-none")) { + this.resultContainer.classList.remove("d-none"); } + } else if (content && !node.attrs.showResult) { if (this.showButton.classList.contains("d-none")) { this.showButton.classList.remove("d-none"); } if (!this.hideButton.classList.contains("d-none")) { this.hideButton.classList.add("d-none"); } + if (!this.resultContainer.classList.contains("d-none")) { + this.resultContainer.classList.add("d-none"); + } } //Fullscreen the results, if the node meta needs it if (content && node.attrs.fullscreen) { - if ( - !this.resultControlsContainer.classList.contains( - "snippet-fullscreen" - ) - ) { - this.resultControlsContainer.classList.add( - "snippet-fullscreen" - ); + if (!this.dom.classList.contains("snippet-fullscreen")) { + //We use `.snippet-fullscreen` as a marker for the rest of the styling + this.dom.classList.add("snippet-fullscreen"); + this.dom.classList.add("ps-fixed"); + this.dom.classList.add("t6"); + this.dom.classList.add("l6"); + this.dom.classList.add("z-modal"); + this.dom.classList.add("w-screen"); + this.dom.classList.add("h-screen"); + this.dom.style.setProperty("background-color", "var(--white)") + } + if (!this.fullscreenButton?.classList.contains("d-none")) { + this.fullscreenButton?.classList.add("d-none"); } - if (this.fullscreenControls.classList.contains("d-none")) { - this.fullscreenControls.classList.remove("d-none"); + if (this.fullscreenReturnButton?.classList.contains("d-none")) { + this.fullscreenReturnButton.classList.remove("d-none"); } } else { - if ( - this.resultControlsContainer.classList.contains( - "snippet-fullscreen" - ) - ) { - this.resultControlsContainer.classList.remove( - "snippet-fullscreen" - ); + if (this.dom.classList.contains("snippet-fullscreen")) { + //We use `.snippet-fullscreen` as a marker for the rest of the styling + this.dom.classList.remove("snippet-fullscreen"); + this.dom.classList.remove("ps-fixed"); + this.dom.classList.remove("t6"); + this.dom.classList.remove("l6"); + this.dom.classList.remove("z-modal"); + this.dom.classList.remove("w-screen"); + this.dom.classList.remove("h-screen"); + } + if (this.fullscreenButton?.classList.contains("d-none")) { + this.fullscreenButton.classList.remove("d-none"); } - if (!this.fullscreenControls.classList.contains("d-none")) { - this.fullscreenControls.classList.add("d-none"); + if (!this.fullscreenReturnButton?.classList.contains("d-none")) { + this.fullscreenReturnButton?.classList.add("d-none"); } } - if (metaChanged && content) { + //Re-run execution the snippet if something has changed, or we don't yet have a result + if (content && (metaChanged || this.resultContainer.innerHTML === "")) { this.snippetResultButtonContainer.classList.remove("d-none"); //Clear the node this.resultContainer.innerHTML = ""; @@ -244,16 +249,14 @@ export class StackSnippetView implements NodeView { private snippetResultButtonContainer: HTMLDivElement; private showButton: HTMLButtonElement; private hideButton: HTMLButtonElement; - private resultControlsContainer: HTMLDivElement; - private fullscreenControls: HTMLDivElement; + private fullscreenButton: HTMLButtonElement; + private fullscreenReturnButton: HTMLButtonElement; + private node: ProseMirrorNode; resultContainer: HTMLDivElement; dom: HTMLElement; contentDOM: HTMLElement; - private buildRunButton( - node: ProseMirrorNode, - container: HTMLDivElement - ): void { + private buildRunButton(container: HTMLDivElement): void { const runCodeButton = document.createElement("button"); runCodeButton.type = "button"; runCodeButton.className = "s-btn s-btn__filled flex--item"; @@ -292,7 +295,7 @@ export class StackSnippetView implements NodeView { this.getPos(), null, { - ...node.attrs, + ...this.node.attrs, content: this.snippetMetadata, } ) @@ -316,10 +319,7 @@ export class StackSnippetView implements NodeView { container.appendChild(runCodeButton); } - private buildEditButton( - node: ProseMirrorNode, - container: HTMLDivElement - ): HTMLButtonElement { + private buildEditButton(container: HTMLDivElement): HTMLButtonElement { const editButton = document.createElement("button"); editButton.type = "button"; editButton.className = "s-btn s-btn__outlined flex--item"; @@ -327,17 +327,14 @@ export class StackSnippetView implements NodeView { editButton.setAttribute("aria-label", "Edit code snippet"); editButton.textContent = "Edit code snippet"; editButton.addEventListener("click", () => { - openSnippetModal(node, this.view, this.opts); + openSnippetModal(this.node, this.view, this.opts); }); container.appendChild(editButton); return editButton; } - private buildHideButton( - node: ProseMirrorNode, - container: HTMLDivElement - ): HTMLButtonElement { + private buildHideButton(container: HTMLDivElement): HTMLButtonElement { const hideButton = document.createElement("button"); hideButton.type = "button"; hideButton.className = "s-btn flex--item"; @@ -353,7 +350,7 @@ export class StackSnippetView implements NodeView { //Trigger an update on the ProseMirror node this.view.dispatch( this.view.state.tr.setNodeMarkup(this.getPos(), null, { - ...node.attrs, + ...this.node.attrs, showResult: false, }) ); @@ -363,10 +360,7 @@ export class StackSnippetView implements NodeView { return hideButton; } - private buildShowButton( - node: ProseMirrorNode, - container: HTMLDivElement - ): HTMLButtonElement { + private buildShowButton(container: HTMLDivElement): HTMLButtonElement { const showButton = document.createElement("button"); showButton.type = "button"; showButton.className = "s-btn flex--item d-none"; @@ -382,7 +376,7 @@ export class StackSnippetView implements NodeView { //Trigger an update on the ProseMirror node this.view.dispatch( this.view.state.tr.setNodeMarkup(this.getPos(), null, { - ...node.attrs, + ...this.node.attrs, showResult: true, }) ); @@ -394,7 +388,6 @@ export class StackSnippetView implements NodeView { } private buildFullscreenExpandButton( - node: ProseMirrorNode, container: HTMLDivElement ): HTMLButtonElement { const expandButton = document.createElement("button"); @@ -406,7 +399,7 @@ export class StackSnippetView implements NodeView { //Trigger an update on the ProseMirror node this.view.dispatch( this.view.state.tr.setNodeMarkup(this.getPos(), null, { - ...node.attrs, + ...this.node.attrs, fullscreen: true, }) ); @@ -423,7 +416,6 @@ export class StackSnippetView implements NodeView { } private buildFullscreenCollapseButton( - node: ProseMirrorNode, container: HTMLDivElement ): HTMLButtonElement { const collapseButton = document.createElement("button"); @@ -436,7 +428,7 @@ export class StackSnippetView implements NodeView { //Trigger an update on the ProseMirror node this.view.dispatch( this.view.state.tr.setNodeMarkup(this.getPos(), null, { - ...node.attrs, + ...this.node.attrs, fullscreen: false, }) ); diff --git a/site/site.css b/site/site.css index 1b496df4..e1ccb7c0 100644 --- a/site/site.css +++ b/site/site.css @@ -1,6 +1,5 @@ @import "~@stackoverflow/stacks/dist/css/stacks.css"; @import "../src/styles/index.css"; -@import "../plugins/official/index.css"; /* Place demo specific styles here */ body.themed.theme-custom, diff --git a/src/styles/icons.css b/src/styles/icons.css index 52c85ca3..29594a61 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -28,6 +28,7 @@ --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/StackSnippets.svg"); } +/* Required for the plugin/official/stackSnippet plugin */ .svg-icon-bg.iconShareSm { width: 18px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ShareSm.svg"); From 8d37e23d50956f1895363cb90b21a6f084266ae8 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Tue, 13 May 2025 11:13:08 +0100 Subject: [PATCH 7/9] linting --- plugins/official/stack-snippets/src/snippet-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 6ee8674f..748361d3 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -193,7 +193,7 @@ export class StackSnippetView implements NodeView { this.dom.classList.add("z-modal"); this.dom.classList.add("w-screen"); this.dom.classList.add("h-screen"); - this.dom.style.setProperty("background-color", "var(--white)") + this.dom.style.setProperty("background-color", "var(--white)"); } if (!this.fullscreenButton?.classList.contains("d-none")) { this.fullscreenButton?.classList.add("d-none"); From 55b7f4a16c19ccb665ddef40d4812d61a95aae71 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Tue, 13 May 2025 14:33:05 +0100 Subject: [PATCH 8/9] Move to a more succinct add/remove of fullscreen atomic CSS classes --- .../stack-snippets/src/snippet-view.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 748361d3..44414188 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -187,12 +187,7 @@ export class StackSnippetView implements NodeView { if (!this.dom.classList.contains("snippet-fullscreen")) { //We use `.snippet-fullscreen` as a marker for the rest of the styling this.dom.classList.add("snippet-fullscreen"); - this.dom.classList.add("ps-fixed"); - this.dom.classList.add("t6"); - this.dom.classList.add("l6"); - this.dom.classList.add("z-modal"); - this.dom.classList.add("w-screen"); - this.dom.classList.add("h-screen"); + this.dom.classList.add(...this.fullscreenClassList); this.dom.style.setProperty("background-color", "var(--white)"); } if (!this.fullscreenButton?.classList.contains("d-none")) { @@ -205,12 +200,7 @@ export class StackSnippetView implements NodeView { if (this.dom.classList.contains("snippet-fullscreen")) { //We use `.snippet-fullscreen` as a marker for the rest of the styling this.dom.classList.remove("snippet-fullscreen"); - this.dom.classList.remove("ps-fixed"); - this.dom.classList.remove("t6"); - this.dom.classList.remove("l6"); - this.dom.classList.remove("z-modal"); - this.dom.classList.remove("w-screen"); - this.dom.classList.remove("h-screen"); + this.dom.classList.remove(...this.fullscreenClassList); } if (this.fullscreenButton?.classList.contains("d-none")) { this.fullscreenButton.classList.remove("d-none"); @@ -256,6 +246,15 @@ export class StackSnippetView implements NodeView { dom: HTMLElement; contentDOM: HTMLElement; + private readonly fullscreenClassList = [ + "ps-fixed", + "t6", + "l6", + "z-modal", + "w-screen", + "h-screen", + ]; + private buildRunButton(container: HTMLDivElement): void { const runCodeButton = document.createElement("button"); runCodeButton.type = "button"; From 7a21cddb4ec3e22201229589d1cc9c44694fd6ab Mon Sep 17 00:00:00 2001 From: James Boyden Date: Wed, 14 May 2025 10:50:54 +0100 Subject: [PATCH 9/9] Update icon sizing and button visibility --- .../stack-snippets/src/snippet-view.ts | 29 ++++++++++++------- src/styles/icons.css | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 44414188..68036e42 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -95,20 +95,23 @@ export class StackSnippetView implements NodeView { const snippetResultButtonContainer = document.createElement("div"); snippetResultButtonContainer.className = - "snippet-result-buttons d-flex mb0 d-none ml-auto gs4"; + "snippet-result-buttons d-flex mb0 ml-auto gs4"; ctas.appendChild(snippetResultButtonContainer); this.showButton = this.buildShowButton( snippetResultButtonContainer ); + this.showButton.classList.add("d-none"); this.hideButton = this.buildHideButton( snippetResultButtonContainer ); + this.hideButton.classList.add("d-none"); this.fullscreenButton = this.buildFullscreenExpandButton( snippetResultButtonContainer ); this.fullscreenReturnButton = this.buildFullscreenCollapseButton( snippetResultButtonContainer ); + this.fullscreenReturnButton.classList.add("d-none"); this.snippetResultButtonContainer = snippetResultButtonContainer; } @@ -183,7 +186,7 @@ export class StackSnippetView implements NodeView { } //Fullscreen the results, if the node meta needs it - if (content && node.attrs.fullscreen) { + if (node.attrs.fullscreen) { if (!this.dom.classList.contains("snippet-fullscreen")) { //We use `.snippet-fullscreen` as a marker for the rest of the styling this.dom.classList.add("snippet-fullscreen"); @@ -212,7 +215,7 @@ export class StackSnippetView implements NodeView { //Re-run execution the snippet if something has changed, or we don't yet have a result if (content && (metaChanged || this.resultContainer.innerHTML === "")) { - this.snippetResultButtonContainer.classList.remove("d-none"); + this.hideButton.classList.remove("d-none"); //Clear the node this.resultContainer.innerHTML = ""; const iframe = document.createElement("iframe"); @@ -263,7 +266,7 @@ export class StackSnippetView implements NodeView { runCodeButton.setAttribute("aria-label", "Run code snippet"); // create the svg svg-icon-bg element const runIcon = document.createElement("span"); - runIcon.className = "svg-icon-bg iconPlay"; + runIcon.className = "svg-icon-bg mr4 iconPlay"; runCodeButton.append(runIcon); const runText = document.createElement("span"); runText.textContent = "Run code snippet"; @@ -340,7 +343,7 @@ export class StackSnippetView implements NodeView { hideButton.title = "Hide results"; hideButton.setAttribute("aria-label", "Hide results"); const hideIcon = document.createElement("span"); - hideIcon.className = "svg-icon-bg iconEyeOff"; + hideIcon.className = "svg-icon-bg mr4 iconEyeOff"; hideButton.append(hideIcon); const hideText = document.createElement("span"); hideText.textContent = "Hide results"; @@ -366,7 +369,7 @@ export class StackSnippetView implements NodeView { showButton.title = "Show results"; showButton.setAttribute("aria-label", "Show results"); const hideIcon = document.createElement("span"); - hideIcon.className = "svg-icon-bg iconEye"; + hideIcon.className = "svg-icon-bg mr4 iconEye"; showButton.append(hideIcon); const showText = document.createElement("span"); showText.textContent = "Show results"; @@ -404,7 +407,7 @@ export class StackSnippetView implements NodeView { ); }); const expandIcon = document.createElement("span"); - expandIcon.className = "svg-icon-bg iconShareSm"; + expandIcon.className = "svg-icon-bg mr4 iconShareSm"; expandButton.append(expandIcon); const expandText = document.createElement("span"); expandText.textContent = "Expand Snippet"; @@ -420,9 +423,8 @@ export class StackSnippetView implements NodeView { const collapseButton = document.createElement("button"); collapseButton.type = "button"; collapseButton.className = "s-btn flex--item td-underline ml-auto"; - collapseButton.title = "Close Snippet"; - collapseButton.textContent = "Close"; - collapseButton.setAttribute("aria-label", "Close Snippet"); + collapseButton.title = "Return to post"; + collapseButton.setAttribute("aria-label", "Return to post"); collapseButton.addEventListener("click", () => { //Trigger an update on the ProseMirror node this.view.dispatch( @@ -432,6 +434,13 @@ export class StackSnippetView implements NodeView { }) ); }); + const collapseIcon = document.createElement("span"); + collapseIcon.className = "svg-icon-bg mr4 iconShareSm"; + collapseButton.append(collapseIcon); + const expandText = document.createElement("span"); + expandText.textContent = "Return to post"; + collapseButton.appendChild(expandText); + container.appendChild(collapseButton); return collapseButton; } diff --git a/src/styles/icons.css b/src/styles/icons.css index 29594a61..ec6ce46c 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -30,7 +30,7 @@ /* Required for the plugin/official/stackSnippet plugin */ .svg-icon-bg.iconShareSm { - width: 18px; + width: 14px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ShareSm.svg"); }