diff --git a/.changeset/hip-forks-kiss.md b/.changeset/hip-forks-kiss.md new file mode 100644 index 00000000..8908d261 --- /dev/null +++ b/.changeset/hip-forks-kiss.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +Add the ability to edit a Snippet in place (via callback) diff --git a/plugins/official/stack-snippets/src/commands.ts b/plugins/official/stack-snippets/src/commands.ts index 87d8dc7a..d7c32e7b 100644 --- a/plugins/official/stack-snippets/src/commands.ts +++ b/plugins/official/stack-snippets/src/commands.ts @@ -1,9 +1,70 @@ import { MenuCommand } from "../../../../src"; -import { getSnippetMetadata, StackSnippetOptions } from "./common"; +import { + getSnippetMetadata, + SnippetMetadata, + StackSnippetOptions, +} from "./common"; import { Node } from "prosemirror-model"; +import { EditorView } from "prosemirror-view"; +import { BASE_VIEW_KEY } from "../../../../src/shared/prosemirror-plugins/base-view-state"; + +/** Builds a function that will update a snippet node on the up-to-date state (at time of execution) **/ +function buildUpdateDocumentCallback(view: EditorView) { + return (markdown: string, id?: SnippetMetadata["id"]): void => { + //Search for the id + let identifiedNode: Node; + let identifiedPos: number; + if (id !== undefined) { + view.state.doc.descendants((node, pos) => { + if (node.type.name == "stack_snippet" && node.attrs?.id == id) { + identifiedNode = node; + identifiedPos = pos; + } + + //We never want to delve into children + return false; + }); + } + + //Get an entrypoint into the BaseView we're in currently + const { baseView } = BASE_VIEW_KEY.getState(view.state); + + //We didn't find something to replace, so we're inserting it + if (!identifiedNode) { + baseView.appendContent(markdown); + } else { + //Parse the incoming markdown as a Prosemirror node using the same entry point as everything else + // (this makes sure there's a single pathway for parsing content) + const parsedNodeDoc: Node = baseView.parseContent(markdown); + let node: Node; + if (parsedNodeDoc.childCount != 1) { + //There's been a parsing error. Put the whole doc in it's place. + node = parsedNodeDoc; + } else { + //The parsed node has a new ID, but we want to maintain it. + // That said, we can only amend Attrs on a rendered node, but doing so makes for a busy + // transaction dispatch history + //Solution: Reparse the node, amending the JSON inbetween. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const snippetNodeJson = parsedNodeDoc.firstChild.toJSON(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + snippetNodeJson.attrs.id = id; + node = Node.fromJSON(view.state.schema, snippetNodeJson); + } + + view.dispatch( + view.state.tr.replaceWith( + identifiedPos, + identifiedPos + identifiedNode.nodeSize, + node + ) + ); + } + }; +} export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { - return (state, dispatch): boolean => { + return (state, dispatch, view): boolean => { //If we have no means of opening a modal, reject immediately if (!options || options.openSnippetsModal == undefined) { return false; @@ -29,7 +90,7 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { //Just grab the first node highlighted and dispatch that. If not, dispatch nothing if (discoveredSnippets.length == 0) { //Fire the open modal handler with nothing - options.openSnippetsModal(); + options.openSnippetsModal(buildUpdateDocumentCallback(view)); return true; } @@ -45,6 +106,7 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { ); options.openSnippetsModal( + buildUpdateDocumentCallback(view), snippetMetadata, js?.content, css?.content, diff --git a/plugins/official/stack-snippets/src/common.ts b/plugins/official/stack-snippets/src/common.ts index 13bef5ed..9568422a 100644 --- a/plugins/official/stack-snippets/src/common.ts +++ b/plugins/official/stack-snippets/src/common.ts @@ -12,6 +12,10 @@ export interface StackSnippetOptions { /** Function to trigger opening of the snippets Modal */ openSnippetsModal: ( + updateDocumentCallback: ( + markdown: string, + id?: SnippetMetadata["id"] + ) => void, meta?: SnippetMetadata, js?: string, css?: string, diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index 4c5802a9..7b0e00f1 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -1,12 +1,19 @@ +import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import { SnippetMetadata, StackSnippetOptions } from "../src/common"; import { openSnippetModal } from "../src/commands"; import { RichTextHelpers } from "../../../../test"; import { buildSnippetSchema, + snippetExternalProvider, + validBegin, + validEnd, validSnippetRenderCases, } from "./stack-snippet-helpers"; import { parseSnippetBlockForProsemirror } from "../src/paste-handler"; +import { RichTextEditor } from "../../../../src"; +import { stackSnippetPlugin as markdownPlugin } from "../src/schema"; +import MarkdownIt from "markdown-it"; describe("commands", () => { const schema = buildSnippetSchema(); @@ -19,14 +26,14 @@ describe("commands", () => { css?: string, html?: string ) => boolean - ) => { + ): boolean => { let captureMeta: SnippetMetadata = null; let captureJs: string = null; let captureCss: string = null; let captureHtml: string = null; const snippetOptions: StackSnippetOptions = { renderer: () => Promise.resolve(null), - openSnippetsModal: (meta, js, css, html) => { + openSnippetsModal: (_, meta, js, css, html) => { captureMeta = meta; captureJs = js; captureCss = css; @@ -40,73 +47,182 @@ describe("commands", () => { expect( shouldMatchCall(captureMeta, captureJs, captureCss, captureHtml) ).toBe(true); + + //Essentially the expects will mean this is terminated before now. + // We can now expect on this guy to get rid of the linting errors + return true; }; - it("should do nothing if dispatch null", () => { - const snippetOptions: StackSnippetOptions = { - renderer: () => Promise.resolve(null), - openSnippetsModal: () => {}, - }; - const state = RichTextHelpers.createState( - "Here's a paragraph - a text block mind you", - [] - ); + describe("dispatch", () => { + it("should do nothing if dispatch null", () => { + const snippetOptions: StackSnippetOptions = { + renderer: () => Promise.resolve(null), + openSnippetsModal: () => {}, + }; + const state = RichTextHelpers.createState( + "Here's a paragraph - a text block mind you", + [] + ); - const command = openSnippetModal(snippetOptions); + const command = openSnippetModal(snippetOptions); - const ret = command(state, null); + const ret = command(state, null); - expect(ret).toBe(true); - }); + expect(ret).toBe(true); + }); - it("should send openModal with blank arguments if no snippet detected", () => { - const state = RichTextHelpers.createState( - "Here's a paragraph - a text block mind you", - [] - ); + it("should send openModal with blank arguments if no snippet detected", () => { + const state = RichTextHelpers.createState( + "Here's a paragraph - a text block mind you", + [] + ); - whenOpenSnippetCommandCalled(state, (meta, js, css, html) => { - //Expect a blank modal - if (meta || js || css || html) { - return false; - } - return true; + expect( + whenOpenSnippetCommandCalled(state, (meta, js, css, html) => { + //Expect a blank modal + return !(meta || js || css || html); + }) + ).toBe(true); }); + + it.each(validSnippetRenderCases)( + "should send openModal with arguments if snippet detected", + (markdown: string, langs: string[]) => { + //Create a blank doc, then replace the contents (a paragraph node) with the parsed markdown. + let state = EditorState.create({ + schema: schema, + plugins: [], + }); + state = state.apply( + state.tr.replaceRangeWith( + 0, + state.doc.nodeSize - 2, + parseSnippetBlockForProsemirror(schema, markdown) + ) + ); + + //Anywhere selection position is now meaningfully a part of the stack snippet, so open the modal and expect it to be passed + expect( + whenOpenSnippetCommandCalled( + state, + (meta, js, css, html) => { + if (!meta) { + return false; + } + if ("js" in langs) { + if (js === undefined) return false; + } + if ("css" in langs) { + if (css === undefined) return false; + } + if ("html" in langs) { + if (html === undefined) return false; + } + + return true; + } + ) + ).toBe(true); + } + ); }); - it.each(validSnippetRenderCases)( - "should send openModal with blank arguments if snippet detected", - (markdown: string, langs: string[]) => { - //Create a blank doc, then replace the contents (a paragraph node) with the parsed markdown. - let state = EditorState.create({ - schema: schema, - plugins: [], - }); - state = state.apply( - state.tr.replaceRangeWith( - 0, - state.doc.nodeSize - 2, - parseSnippetBlockForProsemirror(schema, markdown) - ) + describe("callback", () => { + const mdit = new MarkdownIt("default", {}); + mdit.use(markdownPlugin); + function richView(markdownInput: string, opts?: StackSnippetOptions) { + return new RichTextEditor( + document.createElement("div"), + markdownInput, + snippetExternalProvider(opts), + {} ); - - //Anywhere selection poision is now meaningfully a part of the stack snippet, so open the modal and expect it to be passed - whenOpenSnippetCommandCalled(state, (meta, js, css, html) => { - if (!meta) { - return false; - } - if ("js" in langs) { - if (js === undefined) return false; - } - if ("css" in langs) { - if (css === undefined) return false; - } - if ("html" in langs) { - if (html === undefined) return false; - } - - return true; - }); } - ); + + const callbackTestCaseJs: string = ` + + console.log("callbackTestCase"); + +`; + const starterCallbackSnippet = `${validBegin}${callbackTestCaseJs}${validEnd}`; + + it.each(validSnippetRenderCases)( + "should replace existing snippet when updateDocumentCallback is called with an ID", + (markdown: string) => { + //Create a blank doc, then replace the contents (a paragraph node) with the parsed markdown. + const view = richView(starterCallbackSnippet); + + //Capture the metadata (for the Id) and the callback + let captureMeta: SnippetMetadata = null; + let captureCallback: ( + markdown: string, + id: SnippetMetadata["id"] + ) => void; + const snippetOptions: StackSnippetOptions = { + renderer: () => Promise.resolve(null), + openSnippetsModal: (updateDocumentCallback, meta) => { + captureMeta = meta; + captureCallback = updateDocumentCallback; + }, + }; + openSnippetModal(snippetOptions)( + view.editorView.state, + () => {}, + view.editorView + ); + + //Call the callback + captureCallback(markdown, captureMeta.id); + + //Assert that the current view state has been changed + let matchingNodes: Node[] = []; + view.editorView.state.doc.descendants((node) => { + if (node.type.name == "stack_snippet") { + if (node.attrs.id == captureMeta.id) { + matchingNodes = [...matchingNodes, node]; + } + } + }); + expect(matchingNodes).toHaveLength(1); + //And that we have replaced the content + expect(matchingNodes[0].textContent).not.toContain( + "callbackTestCase" + ); + } + ); + + it.each(validSnippetRenderCases)( + "should add snippet when updateDocumentCallback is called without an ID", + (markdown: string) => { + //Create a blank doc, then replace the contents (a paragraph node) with the parsed markdown. + const view = richView(""); + + //Capture the metadata (for the Id) and the callback + let captureCallback: (markdown: string) => void; + const snippetOptions: StackSnippetOptions = { + renderer: () => Promise.resolve(null), + openSnippetsModal: (updateDocumentCallback) => { + captureCallback = updateDocumentCallback; + }, + }; + openSnippetModal(snippetOptions)( + view.editorView.state, + () => {}, + view.editorView + ); + + //Call the callback + captureCallback(markdown); + + //Assert that the current view state now includes a snippet + let matchingNodes: Node[] = []; + view.editorView.state.doc.descendants((node) => { + if (node.type.name == "stack_snippet") { + matchingNodes = [...matchingNodes, node]; + } + }); + expect(matchingNodes).toHaveLength(1); + } + ); + }); }); diff --git a/site/index.ts b/site/index.ts index fda4db5a..670101a9 100644 --- a/site/index.ts +++ b/site/index.ts @@ -126,7 +126,7 @@ const ImageUploadHandler: ImageUploadOptions["handler"] = (file) => }); const stackSnippetOpts: StackSnippetOptions = { - renderer: (meta, js, css, html) => { + renderer: async (meta, js, css, html) => { const data = { js: js, css: css, @@ -136,27 +136,26 @@ const stackSnippetOpts: StackSnippetOptions = { babelPresetReact: meta.babelPresetReact, babelPresetTS: meta.babelPresetTS, }; - return fetch("/snippets/js", { - method: "POST", - body: new URLSearchParams(data), - }) - .then((res) => res.text()) - .then((html) => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - return doc; - }) - .catch((err) => { - error("test harness - snippet render", err); - const div = document.createElement("div"); - const freeRealEstate = document.createElement("img"); - freeRealEstate.src = - "https://i.kym-cdn.com/entries/icons/original/000/021/311/free.jpg"; - div.appendChild(freeRealEstate); - return div; + try { + const res = await fetch("/snippets/js", { + method: "POST", + body: new URLSearchParams(data), }); + const html = await res.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + return doc; + } catch (err) { + error("test harness - snippet render", err); + const div = document.createElement("div"); + const freeRealEstate = document.createElement("img"); + freeRealEstate.src = + "https://i.kym-cdn.com/entries/icons/original/000/021/311/free.jpg"; + div.appendChild(freeRealEstate); + return div; + } }, - openSnippetsModal: (meta, js, css, html) => { + openSnippetsModal: (editorCallback, meta, js, css, html) => { log("test harness - open modal event", `meta\n${JSON.stringify(meta)}`); log("test harness - open modal event", `js\n${JSON.stringify(js)}`); log("test harness - open modal event", `css\n${JSON.stringify(css)}`); diff --git a/src/commonmark/editor.ts b/src/commonmark/editor.ts index 204d3fdf..0cf3d97a 100644 --- a/src/commonmark/editor.ts +++ b/src/commonmark/editor.ts @@ -30,6 +30,7 @@ import { commonmarkSchema } from "./schema"; import { textCopyHandlerPlugin } from "./plugins/text-copy-handler"; import { markdownHighlightPlugin } from "./plugins/markdown-highlight"; import { createMenuEntries } from "../shared/menu"; +import { baseViewStatePlugin } from "../shared/prosemirror-plugins/base-view-state"; /** * Describes the callback for when an html preview should be rendered @@ -100,6 +101,7 @@ export class CommonmarkEditor extends BaseView { state: EditorState.create({ doc: this.parseContent(content), plugins: [ + baseViewStatePlugin(this), history(), ...allKeymaps(this.options.parserFeatures), menu, diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index 57135ed0..d8e3c0e9 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -43,6 +43,7 @@ import { interfaceManagerPlugin } from "../shared/prosemirror-plugins/interface- import { IExternalPluginProvider } from "../shared/editor-plugin"; import { createMenuEntries } from "../shared/menu/index"; import { createMenuPlugin } from "../shared/menu/plugin"; +import { baseViewStatePlugin } from "../shared/prosemirror-plugins/base-view-state"; export interface RichTextOptions extends CommonViewOptions { /** Array of LinkPreviewProviders to handle specific link preview urls */ @@ -115,6 +116,7 @@ export class RichTextEditor extends BaseView { state: EditorState.create({ doc: doc, plugins: [ + baseViewStatePlugin(this), history(), ...allKeymaps( this.finalizedSchema, diff --git a/src/shared/prosemirror-plugins/base-view-state.ts b/src/shared/prosemirror-plugins/base-view-state.ts new file mode 100644 index 00000000..50ad3004 --- /dev/null +++ b/src/shared/prosemirror-plugins/base-view-state.ts @@ -0,0 +1,30 @@ +import { PluginKey, Plugin } from "prosemirror-state"; +import { BaseView } from "../view"; + +interface BaseViewState { + baseView: BaseView; +} + +export const BASE_VIEW_KEY = new PluginKey(); + +/** + * A pointer to the full `BaseView` that initialized this state, such that it can be referenced in downstream plugins + **/ +export const baseViewStatePlugin = ( + baseView: BaseView +): Plugin => { + return new Plugin({ + key: BASE_VIEW_KEY, + state: { + init() { + return { + baseView, + }; + }, + apply(_, value) { + //View switching does not maintain state, so we always want the initialized value + return value; + }, + }, + }); +};