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(".js-language-input"); + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ); - // If the language has changed, update the language indicator - if (newLanguageDisplayName !== this.currentLanguageDisplayName) { - this.currentLanguageDisplayName = newLanguageDisplayName; - this.dom.querySelector(".js-language-indicator").textContent = - newLanguageDisplayName; + input.style.display = node.attrs.isEditingLanguage ? "block" : "none"; + + if (node.attrs.isEditingLanguage) { + textbox.focus(); } - return true; - } + const dropdownContainer = this.dom.querySelector( + ".js-language-dropdown-container" + ); - private render() { - this.dom.innerHTML = escapeHTML` -
-
`; + 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( + ".js-language-dropdown" + ); + if (event.key === "Enter") { + event.preventDefault(); + // If an item is focused in the dropdown, select it. + const activeItem = dropdown.querySelector("li:focus"); + if (activeItem) { + (activeItem as HTMLElement).click(); + return; + } + // Otherwise, blur and refocus the editor. This will trigger onLanguageInputBlur to update the language. + this.view.focus(); + } else if (event.key === "Escape") { + this.onEscape(); + } else if (event.key === "ArrowDown") { + this.onArrowDown(event); + } else if (event.key === "ArrowUp") { + this.onArrowUp(event); + } else if (event.key === " ") { + event.preventDefault(); + } + + // Prevent event propagating to the underlying ProseMirror editor (we don't want keypresses turning up there). + event.stopPropagation(); + } + + private onEscape() { + this.ignoreBlur = true; + this.updateNodeAttrs({ + isEditingLanguage: false, + suggestions: null, + }); + this.view.focus(); + } + + private onArrowUp(event: KeyboardEvent) { + this.updateSelectedSuggestionIndex(-1); + event.preventDefault(); + event.stopPropagation(); + } + + private onArrowDown(event: KeyboardEvent) { + this.updateSelectedSuggestionIndex(1); + event.preventDefault(); + event.stopPropagation(); + } + + // Move up or down the list of suggestions when the user presses the arrow keys. + private updateSelectedSuggestionIndex(delta: number) { + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ); + + const liElements = dropdown.querySelectorAll("li"); + if (liElements.length == 0) { + return; + } + + this.selectedSuggestionIndex += delta; + + // Wrap around the suggestions list. Note that -1 means the textbox is selected. + if (this.selectedSuggestionIndex < -1) { + this.selectedSuggestionIndex = liElements.length - 1; + } else if (this.selectedSuggestionIndex >= liElements.length) { + this.selectedSuggestionIndex = -1; + } + + if (this.selectedSuggestionIndex == -1) { + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ); + textbox.focus(); + this.selectedSuggestionIndex = -1; + } else { + (liElements[this.selectedSuggestionIndex] as HTMLElement).focus(); + } + } + + private onLanguageInputMouseDown(event: MouseEvent) { + // this prevents ProseMirror freaking out when triple-clicking the textbox + event.stopPropagation(); + } + + private onLanguageInputTextInput(event: Event) { + const input = event.target as HTMLInputElement; + const query = input.value.toLowerCase(); + const suggestions = + query.length > 0 + ? this.availableLanguages + .filter((lang) => lang.toLowerCase().startsWith(query)) + .slice(0, this.maxSuggestions) + : []; + // Reset the selected suggestion index when the suggestions update. + this.selectedSuggestionIndex = -1; + this.updateNodeAttrs({ + suggestions: suggestions, + }); + } + + private renderDropdown(suggestions: string[]) { + const dropdownContainer = this.dom.querySelector( + ".js-language-dropdown-container" + ); + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ); + + dropdown.innerHTML = ""; + + if (suggestions.length === 0) { + dropdownContainer.style.display = "none"; + this.selectedSuggestionIndex = -1; + return; + } + + // Reset the current selection. + this.selectedSuggestionIndex = -1; + + suggestions.forEach((lang) => { + const li = document.createElement("li"); + li.textContent = lang; + li.classList.add("h:bg-black-150", "px4"); + li.tabIndex = 0; // Make it focusable + + // Prevent the textbox's blur event from closing the dropdown too early when the user clicks on a suggestion. + li.addEventListener("mousedown", (event: MouseEvent) => { + event.preventDefault(); + }); + + // When a list item is clicked, update the language. + li.addEventListener("click", () => { + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ); + textbox.value = lang; + this.updateNodeAttrs({ + params: lang, + isEditingLanguage: false, + suggestions: null, + }); + dropdownContainer.style.display = "none"; + this.view.focus(); + }); + + li.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + li.click(); + } else if (event.key === "Escape") { + this.onEscape(); + } else if (event.key === "ArrowDown") { + this.onArrowDown(event); + } else if (event.key === "ArrowUp") { + this.onArrowUp(event); + } else if (event.key === "Tab") { + // We don't want the Tab keypress making new tabs appear in the editor. + event.stopPropagation(); + } + }); + dropdown.appendChild(li); + }); + + dropdownContainer.style.display = "block"; + } } diff --git a/src/rich-text/schema.ts b/src/rich-text/schema.ts index 8e04e135..7ead02d1 100644 --- a/src/rich-text/schema.ts +++ b/src/rich-text/schema.ts @@ -93,7 +93,8 @@ const nodes: { attrs: { params: { default: "" }, autodetectedLanguage: { default: "" }, - isEditingProcessor: { default: false }, + isEditingLanguage: { default: false }, + suggestions: { default: null }, }, parseDOM: [ { diff --git a/src/shared/markdown-serializer.ts b/src/shared/markdown-serializer.ts index ccf2cab6..158b5c0e 100644 --- a/src/shared/markdown-serializer.ts +++ b/src/shared/markdown-serializer.ts @@ -185,7 +185,12 @@ const defaultMarkdownSerializerNodes: MarkdownSerializerNodes = { }, code_block(state, node) { // TODO could be html... - const markup = (node.attrs.markup as string) || "```"; + let markup = (node.attrs.markup as string) || "```"; + + // if a language has been specified, we can't use an indented code block, so turn it into a fence + if (markup === "indented" && node.attrs.params) { + markup = "```"; + } // indented code blocks have their markup set to "indented" instead of empty if (markup === "indented") { diff --git a/src/styles/icons.css b/src/styles/icons.css index 957685ab..589b1eb7 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -13,3 +13,7 @@ .svg-icon-bg.iconEllipsisHorizontal { --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EllipsisHorizontal.svg"); } +.svg-icon-bg.iconSearchSm { + width: 21px; + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/SearchSm.svg"); +} diff --git a/test/rich-text/commands/code-block.test.ts b/test/rich-text/commands/code-block.test.ts index 6446bd07..028c1bbd 100644 --- a/test/rich-text/commands/code-block.test.ts +++ b/test/rich-text/commands/code-block.test.ts @@ -3,6 +3,7 @@ import { schema as basicSchema } from "prosemirror-schema-basic"; import { doc, p, code_block, br, code } from "prosemirror-test-builder"; import { indentCodeBlockLinesCommand, + openCodeBlockLanguagePicker, toggleCodeBlock, toggleInlineCode, unindentCodeBlockLinesCommand, @@ -731,3 +732,35 @@ describe("toggleInlineCode command", () => { expect(state.doc.toJSON()).toEqual(prevJSON); }); }); + +describe("openCodeBlockLanguagePicker (using createState)", () => { + it("should return true and dispatch setNodeMarkup when inside a
", () => {
+        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") + ); + }); });