diff --git a/.changeset/gold-wombats-jog.md b/.changeset/gold-wombats-jog.md new file mode 100644 index 00000000..683853a0 --- /dev/null +++ b/.changeset/gold-wombats-jog.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +add codeblock language picker diff --git a/site/index.ts b/site/index.ts index fda4db5a..c5a74940 100644 --- a/site/index.ts +++ b/site/index.ts @@ -321,6 +321,15 @@ domReady(() => { ], highlighting: { highlightedNodeTypes: ["stack_snippet_lang"], + languages: [ + "javascript", + "java", + "python", + "ruby", + "rust", + "csharp", + "go", + ], }, }, imageUpload: imageUploadOptions, diff --git a/src/rich-text/commands/code-block.ts b/src/rich-text/commands/code-block.ts index 89f13ad5..d2eae54d 100644 --- a/src/rich-text/commands/code-block.ts +++ b/src/rich-text/commands/code-block.ts @@ -358,3 +358,31 @@ export function toggleInlineCode( // If we found neither newline nor softbreak, toggle the inline code mark. return toggleMark(state.schema.marks.code)(state, dispatch); } + +function isSelectionInCodeBlock( + state: EditorState +): { pos: number; node: ProseMirrorNode } | null { + const { $from } = state.selection; + if ($from.parent.type.name === "code_block") { + return { pos: $from.before(), node: $from.parent }; + } + return null; +} + +export function openCodeBlockLanguagePicker( + state: EditorState, + dispatch: (tr: Transaction) => void +) { + const codeBlock = isSelectionInCodeBlock(state); + if (!codeBlock) { + return false; + } + const { pos, node } = codeBlock; + + // Setting isEditingLanguage to true will open the language picker + const newAttrs = { ...node.attrs, isEditingLanguage: true }; + if (dispatch) { + dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); + } + return true; +} diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index 57135ed0..57e84195 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -50,6 +50,10 @@ export interface RichTextOptions extends CommonViewOptions { highlighting?: { /** Which prosemirror nodes should have highlighting? Defaults to "code_block", which will always be highlighted */ highlightedNodeTypes?: string[]; + /** Which languages appear as suggestions in the dropdown? */ + languages?: string[]; + /** The maximum number of languages to show in the dropdown */ + maxSuggestions?: number; }; } @@ -149,8 +153,18 @@ export class RichTextEditor extends BaseView { ], }), nodeViews: { - code_block: (node) => { - return new CodeBlockView(node); + code_block: ( + node, + view: EditorView, + getPos: () => number + ) => { + return new CodeBlockView( + node, + view, + getPos, + this.options.highlighting?.languages || [], + this.options.highlighting?.maxSuggestions + ); }, image( node: ProseMirrorNode, diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index ef41661b..326c3318 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -33,6 +33,7 @@ import { splitCodeBlockAtStartOfDoc, exitInclusiveMarkCommand, escapeUnselectableCommand, + openCodeBlockLanguagePicker, } from "./commands"; export function allKeymaps( @@ -45,6 +46,7 @@ export function allKeymaps( "Mod-]": indentCodeBlockLinesCommand, "Mod-[": unindentCodeBlockLinesCommand, "Enter": splitCodeBlockAtStartOfDoc, + "Mod-;": openCodeBlockLanguagePicker, }); const tableKeymap = caseNormalizeKeymap({ diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 9002de7c..44bcdd11 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -1,5 +1,5 @@ import { Node as ProsemirrorNode } from "prosemirror-model"; -import { NodeView } from "prosemirror-view"; +import { EditorView, NodeView } from "prosemirror-view"; import { getBlockLanguage } from "../../shared/highlighting/highlight-plugin"; import { _t } from "../../shared/localization"; import { escapeHTML } from "../../shared/utils"; @@ -10,15 +10,79 @@ import { escapeHTML } from "../../shared/utils"; export class CodeBlockView implements NodeView { dom: HTMLElement | null; contentDOM?: HTMLElement | null; + private node: ProsemirrorNode; + private view: EditorView; + private getPos: () => number; + private availableLanguages: string[]; + private maxSuggestions: number; + private ignoreBlur: boolean = false; + private selectedSuggestionIndex: number = -1; - private currentLanguageDisplayName: string = null; + constructor( + node: ProsemirrorNode, + view: EditorView, + getPos: () => number, + availableLanguages: string[], + maxSuggestions: number = 5 + ) { + this.node = node; + this.view = view; + this.getPos = getPos; + this.availableLanguages = availableLanguages; + this.maxSuggestions = maxSuggestions; + this.render(); + } - constructor(node: ProsemirrorNode) { + private render() { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); - this.render(); + this.dom.innerHTML = escapeHTML` + +
`;
+
this.contentDOM = this.dom.querySelector(".content-dom");
- this.update(node);
+
+ const languageSelectorButton = this.dom.querySelector(
+ "button.js-language-selector"
+ );
+ languageSelectorButton.addEventListener(
+ "click",
+ this.onLanguageSelectorClick.bind(this)
+ );
+ languageSelectorButton.addEventListener(
+ "mousedown",
+ this.onLanguageSelectorMouseDown.bind(this)
+ );
+
+ const textbox = this.dom.querySelector(".js-language-input-textbox");
+ textbox.addEventListener("blur", this.onLanguageInputBlur.bind(this));
+ textbox.addEventListener(
+ "keydown",
+ this.onLanguageInputKeyDown.bind(this)
+ );
+ textbox.addEventListener(
+ "mousedown",
+ this.onLanguageInputMouseDown.bind(this)
+ );
+ textbox.addEventListener(
+ "input",
+ this.onLanguageInputTextInput.bind(this)
+ );
+
+ this.update(this.node);
}
update(node: ProsemirrorNode): boolean {
@@ -27,29 +91,39 @@ export class CodeBlockView implements NodeView {
return false;
}
- const newLanguageDisplayName = this.getLanguageDisplayName(node);
+ this.node = node;
+
+ this.dom.querySelector(".js-language-indicator").textContent =
+ this.getLanguageDisplayName();
+
+ const input =
+ this.dom.querySelector`;
+ if (node.attrs.suggestions) {
+ this.renderDropdown(node.attrs.suggestions as string[]);
+ } else {
+ dropdownContainer.style.display = "none";
+ }
- this.contentDOM = this.dom.querySelector(".content-dom");
+ return true;
}
/** Gets the codeblock language from the node */
- private getLanguageDisplayName(node: ProsemirrorNode) {
- const language = getBlockLanguage(node);
+ private getLanguageDisplayName() {
+ const language = getBlockLanguage(this.node);
// for a user-specified language, just return the language name
if (!language.IsAutoDetected) {
@@ -61,4 +135,220 @@ export class CodeBlockView implements NodeView {
lang: language.Language,
});
}
+
+ private updateNodeAttrs(newAttrs: object) {
+ const pos = this.getPos();
+ const nodeAttrs = this.node.attrs;
+ this.view.dispatch(
+ this.view.state.tr.setNodeMarkup(pos, null, {
+ ...nodeAttrs,
+ ...newAttrs,
+ })
+ );
+ }
+
+ private onLanguageSelectorClick(event: MouseEvent) {
+ event.stopPropagation();
+ this.updateNodeAttrs({
+ isEditingLanguage: true,
+ });
+ }
+
+ private onLanguageSelectorMouseDown(event: MouseEvent) {
+ event.stopPropagation();
+ }
+
+ private onLanguageInputBlur(event: FocusEvent) {
+ // If the user pressed Escape, don't update the language.
+ if (this.ignoreBlur) {
+ this.ignoreBlur = false;
+ return;
+ }
+
+ // If the newly focused element is inside the language input container, then the user just tabbed on to
+ // the suggestions list. In this case, we don't want to close the dropdown.
+ const container = this.dom.querySelector(".js-language-input");
+ if (
+ event.relatedTarget &&
+ container &&
+ container.contains(event.relatedTarget as Node)
+ ) {
+ return;
+ }
+
+ const target = event.target as HTMLInputElement;
+ this.updateNodeAttrs({
+ params: target.value,
+ isEditingLanguage: false,
+ suggestions: null,
+ });
+ }
+
+ private onLanguageInputKeyDown(event: KeyboardEvent) {
+ const dropdown = this.dom.querySelector", () => {
+ const state = createState("Some code
", []);
+
+ const dispatch = jest.fn();
+ const result = openCodeBlockLanguagePicker(state, dispatch);
+
+ expect(result).toBe(true);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+
+ const dispatchedTr = dispatch.mock.calls[0][0];
+ const newState = state.apply(dispatchedTr);
+
+ const codeBlock = newState.doc.child(0);
+
+ expect(codeBlock.type.name).toBe("code_block");
+ expect(codeBlock.attrs).toMatchObject({
+ isEditingLanguage: true,
+ });
+ });
+
+ it("should return false and not dispatch when not in a code block", () => {
+ const state = createState("Hello world
", []);
+
+ const dispatch = jest.fn();
+ const result = openCodeBlockLanguagePicker(state, dispatch);
+
+ expect(result).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts
index 0559784a..3e984478 100644
--- a/test/rich-text/node-views/code-block.test.ts
+++ b/test/rich-text/node-views/code-block.test.ts
@@ -1,5 +1,23 @@
+import { EditorPlugin } from "../../../src";
+import { Node as ProseMirrorNode } from "prosemirror-model";
import { RichTextEditor } from "../../../src/rich-text/editor";
import { externalPluginProvider } from "../../test-helpers";
+import { EditorView, EditorProps } from "prosemirror-view";
+import { CodeBlockView } from "../../../src/rich-text/node-views/code-block";
+
+const languages = ["javascript", "python", "ruby"];
+
+const testCodeBlockPlugin: EditorPlugin = () => ({
+ richText: {
+ nodeViews: {
+ code_block: (
+ node: ProseMirrorNode,
+ view: EditorView,
+ getPos: () => number
+ ) => new CodeBlockView(node, view, getPos, languages),
+ } as EditorProps["nodeViews"],
+ },
+});
describe("code-block", () => {
let richText: RichTextEditor;
@@ -12,7 +30,7 @@ describe("code-block", () => {
);
});
- it("should render codeblocks", () => {
+ it("should render a codeblock in a fence", () => {
richText.content = `~~~js
console.log("Hello World");
~~~`;
@@ -34,4 +52,220 @@ console.log("Hello World");
],
});
});
+
+ it("should render an indented codeblock", () => {
+ richText.content = ` console.log("Hello World");`;
+
+ // check the node type
+ expect(richText.editorView.state.doc).toMatchNodeTree({
+ "type.name": "doc",
+ "content": [
+ {
+ "type.name": "code_block",
+ "content": [
+ {
+ "type.name": "text",
+ "text": 'console.log("Hello World");',
+ },
+ ],
+ },
+ ],
+ });
+ });
+});
+
+describe("code-block language picker", () => {
+ let richText: RichTextEditor;
+
+ beforeEach(() => {
+ richText = new RichTextEditor(
+ document.createElement("div"),
+ "",
+ externalPluginProvider([testCodeBlockPlugin])
+ );
+ });
+
+ it("toggles the language input panel when the selector button is clicked", () => {
+ richText.content = `~~~js
+console.log("Hello");
+~~~`;
+
+ const button = richText.editorView.dom.querySelector(
+ "button.js-language-selector"
+ );
+ expect(button).toBeTruthy();
+
+ // initially closed
+ const inputPanel =
+ richText.editorView.dom.querySelector(
+ ".js-language-input"
+ );
+ expect(inputPanel.style.display).toBe("none");
+
+ // open it
+ button.click();
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.isEditingLanguage).toBe(true);
+
+ // panel should now be visible
+ expect(inputPanel.style.display).toBe("block");
+ });
+
+ it("updates suggestions as you type into the language textbox", () => {
+ richText.content = `~~~js
+console.log("Hello");
+~~~`;
+
+ // open the panel first
+ richText.editorView.dom
+ .querySelector("button.js-language-selector")
+ .click();
+
+ const textbox = richText.editorView.dom.querySelector(
+ ".js-language-input-textbox"
+ );
+ // simulate typing "py"
+ textbox.value = "Py";
+ textbox.dispatchEvent(new Event("input", { bubbles: true }));
+
+ // model should get the suggestions array
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.suggestions).toEqual(["python"]);
+
+ // and the dropdown should contain one
+ const items = richText.editorView.dom.querySelectorAll(
+ ".js-language-dropdown li"
+ );
+ expect(items).toHaveLength(1);
+ expect(items[0].textContent).toBe("python");
+ });
+
+ it("sets the language on clicking a suggestion", () => {
+ richText.content = `~~~js
+console.log("Hello");
+~~~`;
+
+ // open and type "ru"
+ richText.editorView.dom
+ .querySelector("button.js-language-selector")
+ .click();
+ const textbox = richText.editorView.dom.querySelector(
+ ".js-language-input-textbox"
+ );
+ textbox.value = "Ru";
+ textbox.dispatchEvent(new Event("input", { bubbles: true }));
+
+ // click the only suggestion
+ const suggestion = richText.editorView.dom.querySelector(
+ ".js-language-dropdown li"
+ );
+ suggestion.click();
+
+ // model should have updated params → "ruby" and closed the panel
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.params).toBe("ruby");
+ expect(codeNode.attrs.isEditingLanguage).toBe(false);
+
+ const inputPanel =
+ richText.editorView.dom.querySelector(
+ ".js-language-input"
+ );
+ expect(inputPanel.style.display).toBe("none");
+
+ const md = richText.content.trim();
+ expect(md).toBe(["~~~ruby", 'console.log("Hello");', "~~~"].join("\n"));
+ });
+
+ it("commits whatever you typed if you blur without selecting a suggestion", () => {
+ richText.content = `~~~js
+console.log("Hello");
+~~~`;
+
+ // open and type "typescript"
+ richText.editorView.dom
+ .querySelector("button.js-language-selector")
+ .click();
+ const textbox = richText.editorView.dom.querySelector(
+ ".js-language-input-textbox"
+ );
+ textbox.value = "typescript";
+
+ // blur the textbox (simulate losing focus)
+ textbox.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
+
+ // the code_block should now have params = "typescript"
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.params).toBe("typescript");
+ expect(codeNode.attrs.isEditingLanguage).toBe(false);
+ // suggestions should be cleared
+ expect(codeNode.attrs.suggestions).toBeNull();
+ });
+
+ it("cancels editing and closes on Escape key", () => {
+ richText.content = `~~~js
+console.log("Hello");
+~~~`;
+
+ // open panel
+ richText.editorView.dom
+ .querySelector("button.js-language-selector")
+ .click();
+
+ const textbox = richText.editorView.dom.querySelector(
+ ".js-language-input-textbox"
+ );
+ // press Escape
+ const ev = new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ });
+ textbox.dispatchEvent(ev);
+
+ // should cancel and close
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.isEditingLanguage).toBe(false);
+ expect(codeNode.attrs.suggestions).toBeNull();
+ // panel hidden
+ const inputPanel =
+ richText.editorView.dom.querySelector(
+ ".js-language-input"
+ );
+ expect(inputPanel.style.display).toBe("none");
+ });
+
+ it("sets the language on an indented code block", () => {
+ richText.content = ` console.log("Hello");`;
+
+ // open and type "ja"
+ richText.editorView.dom
+ .querySelector("button.js-language-selector")
+ .click();
+ const textbox = richText.editorView.dom.querySelector(
+ ".js-language-input-textbox"
+ );
+ textbox.value = "ja";
+ textbox.dispatchEvent(new Event("input", { bubbles: true }));
+
+ // click the only suggestion
+ const suggestion = richText.editorView.dom.querySelector(
+ ".js-language-dropdown li"
+ );
+ suggestion.click();
+
+ // model should have updated params → "javascript" and closed the panel
+ const codeNode = richText.editorView.state.doc.firstChild;
+ expect(codeNode.attrs.params).toBe("javascript");
+ expect(codeNode.attrs.isEditingLanguage).toBe(false);
+
+ const inputPanel =
+ richText.editorView.dom.querySelector(
+ ".js-language-input"
+ );
+ expect(inputPanel.style.display).toBe("none");
+
+ const md = richText.content.trim();
+ expect(md).toBe(
+ ["```javascript", 'console.log("Hello");', "```"].join("\n")
+ );
+ });
});