Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-forks-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks-editor": minor
---

Add the ability to edit a Snippet in place (via callback)
68 changes: 65 additions & 3 deletions plugins/official/stack-snippets/src/commands.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}

Expand All @@ -45,6 +106,7 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand {
);

options.openSnippetsModal(
buildUpdateDocumentCallback(view),
snippetMetadata,
js?.content,
css?.content,
Expand Down
4 changes: 4 additions & 0 deletions plugins/official/stack-snippets/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
234 changes: 175 additions & 59 deletions plugins/official/stack-snippets/test/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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;
Expand All @@ -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 = `<!-- language: lang-js -->

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);
}
);
});
});
Loading
Loading