From 8d8f6e304e77b4bec9014a73698d644d94be1f74 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Mar 2025 14:26:31 +0000 Subject: [PATCH 01/54] Set language of code block --- src/rich-text/editor.ts | 8 ++++++-- src/rich-text/node-views/code-block.ts | 27 +++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index ac2c102c..30392a55 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -145,8 +145,12 @@ 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); }, image( node: ProseMirrorNode, diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 9002de7c..00d86c48 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,14 +10,22 @@ 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 currentLanguageDisplayName: string = null; - constructor(node: ProsemirrorNode) { + constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { + this.node = node; + this.view = view; + this.getPos = getPos; this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.render(); this.contentDOM = this.dom.querySelector(".content-dom"); + const button = this.dom.querySelector("button"); + button.addEventListener("click", this.onButtonClick.bind(this)); this.update(node); } @@ -27,6 +35,8 @@ export class CodeBlockView implements NodeView { return false; } + this.node = node; + const newLanguageDisplayName = this.getLanguageDisplayName(node); // If the language has changed, update the language indicator @@ -41,7 +51,8 @@ export class CodeBlockView implements NodeView { private render() { this.dom.innerHTML = escapeHTML` -
+
+
`; this.contentDOM = this.dom.querySelector(".content-dom"); @@ -61,4 +72,14 @@ export class CodeBlockView implements NodeView { lang: language.Language, }); } + + private onButtonClick(event: MouseEvent) { + const pos = this.getPos(); + const newAttrs = { + ...this.node.attrs, + params: "javascript", + }; + const { state, dispatch } = this.view; + dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); + } } From 8cacc8b331d004702669aa005607c6933a7659f2 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Mar 2025 14:44:49 +0000 Subject: [PATCH 02/54] Fix linting --- src/rich-text/node-views/code-block.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 00d86c48..117af984 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -10,12 +10,11 @@ import { escapeHTML } from "../../shared/utils"; export class CodeBlockView implements NodeView { dom: HTMLElement | null; contentDOM?: HTMLElement | null; + private currentLanguageDisplayName: string = null; private node: ProsemirrorNode; private view: EditorView; private getPos: () => number; - private currentLanguageDisplayName: string = null; - constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { this.node = node; this.view = view; @@ -73,13 +72,14 @@ export class CodeBlockView implements NodeView { }); } - private onButtonClick(event: MouseEvent) { + private onButtonClick() { const pos = this.getPos(); const newAttrs = { ...this.node.attrs, params: "javascript", }; - const { state, dispatch } = this.view; - dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, undefined, newAttrs) + ); } } From 73ee2d4e6ef1482fd4692a91d6d7534c3a358557 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Mar 2025 16:14:34 +0000 Subject: [PATCH 03/54] Don't need tabindex set --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 117af984..ea4a7ab5 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -51,7 +51,7 @@ export class CodeBlockView implements NodeView { private render() { this.dom.innerHTML = escapeHTML`
- +
`; this.contentDOM = this.dom.querySelector(".content-dom"); From 32a311460c2ccc77f854e0261d5164e3cdb74c77 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Mar 2025 16:34:57 +0000 Subject: [PATCH 04/54] Make language indicator clickable --- src/rich-text/node-views/code-block.ts | 6 ++++-- src/styles/icons.css | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index ea4a7ab5..6cd7d628 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -50,8 +50,10 @@ export class CodeBlockView implements NodeView { private render() { this.dom.innerHTML = escapeHTML` -
- +
`; this.contentDOM = this.dom.querySelector(".content-dom"); diff --git a/src/styles/icons.css b/src/styles/icons.css index 957685ab..bc129e93 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.iconArrowDownSm { + width: 21px; + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowDownSm.svg"); +} \ No newline at end of file From 05fbf360d6f7f7b8eae136de0e0331a4767138ab Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 11:24:19 +0100 Subject: [PATCH 05/54] Wire it up --- src/rich-text/node-views/code-block.ts | 75 ++++++++++++++++++++------ src/rich-text/schema.ts | 2 +- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 6cd7d628..33e2fda4 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -19,13 +19,30 @@ export class CodeBlockView implements NodeView { this.node = node; this.view = view; this.getPos = getPos; + this.render(); + } + + 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"); - const button = this.dom.querySelector("button"); - button.addEventListener("click", this.onButtonClick.bind(this)); - this.update(node); + + const languageSelectorButton = this.dom.querySelector("button.js-language-selector"); + languageSelectorButton.addEventListener("click", this.onLanguageSelectorClick.bind(this)); + + const languageInput = this.dom.querySelector(".js-language-input") as HTMLInputElement; + languageInput.addEventListener("blur", this.onLanguageInputBlur.bind(this)); + languageInput.addEventListener("keydown", this.onLanguageInputKeyDown.bind(this)); + + this.update(this.node); } update(node: ProsemirrorNode): boolean { @@ -45,18 +62,17 @@ export class CodeBlockView implements NodeView { newLanguageDisplayName; } - return true; - } + const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; - private render() { - this.dom.innerHTML = escapeHTML` - -
`; + if (node.attrs.isEditingLanguage) { + input.style.display = "block"; + } + else { + input.style.display = "none"; + } + - this.contentDOM = this.dom.querySelector(".content-dom"); + return true; } /** Gets the codeblock language from the node */ @@ -74,14 +90,41 @@ export class CodeBlockView implements NodeView { }); } - private onButtonClick() { + private onLanguageSelectorClick(event: MouseEvent) { + event.stopPropagation(); + + const pos = this.getPos(); + const nodeAttrs = this.view.state.doc.nodeAt(pos).attrs; + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, null, { + ...nodeAttrs, + isEditingLanguage: true, + }) + ); + + const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + input.style.display = "block"; + input.focus(); + } + + private onLanguageInputBlur(event: FocusEvent) { + const target = event.target as HTMLInputElement; const pos = this.getPos(); const newAttrs = { ...this.node.attrs, - params: "javascript", + params: target.value, }; this.view.dispatch( this.view.state.tr.setNodeMarkup(pos, undefined, newAttrs) ); } + + private onLanguageInputKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLInputElement; + if (event.key === "Enter") { + target.blur(); + event.preventDefault(); + event.stopPropagation(); + } + } } diff --git a/src/rich-text/schema.ts b/src/rich-text/schema.ts index 8e04e135..dec3977c 100644 --- a/src/rich-text/schema.ts +++ b/src/rich-text/schema.ts @@ -93,7 +93,7 @@ const nodes: { attrs: { params: { default: "" }, autodetectedLanguage: { default: "" }, - isEditingProcessor: { default: false }, + isEditingLanguage: { default: false }, }, parseDOM: [ { From b2240c43a3028f6be1cec6a3e984e76abf0778c2 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 11:33:59 +0100 Subject: [PATCH 06/54] Refactor and format --- src/rich-text/node-views/code-block.ts | 64 ++++++++++++++++---------- src/styles/icons.css | 2 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 33e2fda4..e9f638e9 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -34,14 +34,25 @@ export class CodeBlockView implements NodeView {
`; this.contentDOM = this.dom.querySelector(".content-dom"); - - const languageSelectorButton = this.dom.querySelector("button.js-language-selector"); - languageSelectorButton.addEventListener("click", this.onLanguageSelectorClick.bind(this)); - - const languageInput = this.dom.querySelector(".js-language-input") as HTMLInputElement; - languageInput.addEventListener("blur", this.onLanguageInputBlur.bind(this)); - languageInput.addEventListener("keydown", this.onLanguageInputKeyDown.bind(this)); - + + const languageSelectorButton = this.dom.querySelector( + "button.js-language-selector" + ); + languageSelectorButton.addEventListener( + "click", + this.onLanguageSelectorClick.bind(this) + ); + + const languageInput = this.dom.querySelector(".js-language-input"); + languageInput.addEventListener( + "blur", + this.onLanguageInputBlur.bind(this) + ); + languageInput.addEventListener( + "keydown", + this.onLanguageInputKeyDown.bind(this) + ); + this.update(this.node); } @@ -62,15 +73,15 @@ export class CodeBlockView implements NodeView { newLanguageDisplayName; } - const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + const input = this.dom.querySelector( + ".js-language-input" + ) as HTMLInputElement; if (node.attrs.isEditingLanguage) { input.style.display = "block"; - } - else { + } else { input.style.display = "none"; } - return true; } @@ -90,33 +101,38 @@ export class CodeBlockView implements NodeView { }); } - private onLanguageSelectorClick(event: MouseEvent) { - event.stopPropagation(); - + private updateNodeAttrs(newAttrs: object) { const pos = this.getPos(); const nodeAttrs = this.view.state.doc.nodeAt(pos).attrs; this.view.dispatch( this.view.state.tr.setNodeMarkup(pos, null, { ...nodeAttrs, - isEditingLanguage: true, + ...newAttrs, }) ); + } + + private onLanguageSelectorClick(event: MouseEvent) { + event.stopPropagation(); + + this.updateNodeAttrs({ + isEditingLanguage: true, + }); - const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + const input = this.dom.querySelector( + ".js-language-input" + ) as HTMLInputElement; input.style.display = "block"; input.focus(); } private onLanguageInputBlur(event: FocusEvent) { const target = event.target as HTMLInputElement; - const pos = this.getPos(); - const newAttrs = { - ...this.node.attrs, + + this.updateNodeAttrs({ params: target.value, - }; - this.view.dispatch( - this.view.state.tr.setNodeMarkup(pos, undefined, newAttrs) - ); + isEditingLanguage: false, + }); } private onLanguageInputKeyDown(event: KeyboardEvent) { diff --git a/src/styles/icons.css b/src/styles/icons.css index bc129e93..b0589e88 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -16,4 +16,4 @@ .svg-icon-bg.iconArrowDownSm { width: 21px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowDownSm.svg"); -} \ No newline at end of file +} From fc83a0eb0a71c3e31bb7a313228aa28e3a77f1bc Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 11:38:18 +0100 Subject: [PATCH 07/54] The linter is stupid --- src/rich-text/node-views/code-block.ts | 27 +++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index e9f638e9..c746ccde 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -73,14 +73,14 @@ export class CodeBlockView implements NodeView { newLanguageDisplayName; } - const input = this.dom.querySelector( - ".js-language-input" - ) as HTMLInputElement; - - if (node.attrs.isEditingLanguage) { - input.style.display = "block"; - } else { - input.style.display = "none"; + const input = this.dom.querySelector(".js-language-input"); + + if (input instanceof HTMLInputElement) { + if (node.attrs.isEditingLanguage) { + input.style.display = "block"; + } else { + input.style.display = "none"; + } } return true; @@ -119,11 +119,12 @@ export class CodeBlockView implements NodeView { isEditingLanguage: true, }); - const input = this.dom.querySelector( - ".js-language-input" - ) as HTMLInputElement; - input.style.display = "block"; - input.focus(); + const input = this.dom.querySelector(".js-language-input"); + + if (input instanceof HTMLInputElement) { + input.style.display = "block"; + input.focus(); + } } private onLanguageInputBlur(event: FocusEvent) { From e71bb1b099e723c53b907427deea15e2da2d0194 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 11:53:06 +0100 Subject: [PATCH 08/54] Focus back on editor when pressing Enter --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index c746ccde..97d563e9 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -139,7 +139,7 @@ export class CodeBlockView implements NodeView { private onLanguageInputKeyDown(event: KeyboardEvent) { const target = event.target as HTMLInputElement; if (event.key === "Enter") { - target.blur(); + this.view.focus(); event.preventDefault(); event.stopPropagation(); } From 0c6fed0428439dce78f88c2c1b98a4100019ad30 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 12:02:50 +0100 Subject: [PATCH 09/54] Always stop propagation of textbox keys --- src/rich-text/node-views/code-block.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 97d563e9..8191d8ae 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -137,11 +137,10 @@ export class CodeBlockView implements NodeView { } private onLanguageInputKeyDown(event: KeyboardEvent) { - const target = event.target as HTMLInputElement; if (event.key === "Enter") { this.view.focus(); event.preventDefault(); - event.stopPropagation(); } + event.stopPropagation(); } } From 9a32fca7440bc2c57b147fb639ae2e659a6d3cfd Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 12:05:40 +0100 Subject: [PATCH 10/54] Populate current language --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 8191d8ae..dbcc4d6d 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -122,6 +122,7 @@ export class CodeBlockView implements NodeView { const input = this.dom.querySelector(".js-language-input"); if (input instanceof HTMLInputElement) { + input.value = this.currentLanguageDisplayName; input.style.display = "block"; input.focus(); } @@ -139,7 +140,6 @@ export class CodeBlockView implements NodeView { private onLanguageInputKeyDown(event: KeyboardEvent) { if (event.key === "Enter") { this.view.focus(); - event.preventDefault(); } event.stopPropagation(); } From 056e4b845027a1dcba0e90cb6d846b3231339bf1 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 12:13:06 +0100 Subject: [PATCH 11/54] Prevent ProseMirror freaking out on triple-clicking textbox --- src/rich-text/node-views/code-block.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index dbcc4d6d..3797e223 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -52,6 +52,10 @@ export class CodeBlockView implements NodeView { "keydown", this.onLanguageInputKeyDown.bind(this) ); + languageInput.addEventListener( + "mousedown", + this.onLanguageInputMouseDown.bind(this) + ); this.update(this.node); } @@ -103,7 +107,7 @@ export class CodeBlockView implements NodeView { private updateNodeAttrs(newAttrs: object) { const pos = this.getPos(); - const nodeAttrs = this.view.state.doc.nodeAt(pos).attrs; + const nodeAttrs = this.node.attrs; this.view.dispatch( this.view.state.tr.setNodeMarkup(pos, null, { ...nodeAttrs, @@ -143,4 +147,8 @@ export class CodeBlockView implements NodeView { } event.stopPropagation(); } + + private onLanguageInputMouseDown(event: MouseEvent) { + event.stopPropagation(); + } } From e46c97127e9ed860c1370add4552ddf707774ad9 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 4 Apr 2025 12:17:47 +0100 Subject: [PATCH 12/54] Language selector needs the same thing --- src/rich-text/node-views/code-block.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 3797e223..687742e7 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -42,6 +42,10 @@ export class CodeBlockView implements NodeView { "click", this.onLanguageSelectorClick.bind(this) ); + languageSelectorButton.addEventListener( + "mousedown", + this.onLanguageSelectorMouseDown.bind(this) + ); const languageInput = this.dom.querySelector(".js-language-input"); languageInput.addEventListener( @@ -132,6 +136,10 @@ export class CodeBlockView implements NodeView { } } + private onLanguageSelectorMouseDown(event: MouseEvent) { + event.stopPropagation(); + } + private onLanguageInputBlur(event: FocusEvent) { const target = event.target as HTMLInputElement; From 2cb0146f912b67547ea32451931847dc35ad9327 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 7 Apr 2025 14:06:56 +0100 Subject: [PATCH 13/54] Adding suggestions dropdown --- src/rich-text/node-views/code-block.ts | 83 ++++++++++++++++++++++++++ src/rich-text/schema.ts | 1 + 2 files changed, 84 insertions(+) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 687742e7..6885ff11 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -15,6 +15,16 @@ export class CodeBlockView implements NodeView { private view: EditorView; private getPos: () => number; + // Temporarily hardcoding this for now + private availableLanguages = [ + "javascript", + "java", + "python", + "ruby", + "csharp", + "go", + ]; + constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { this.node = node; this.view = view; @@ -31,6 +41,7 @@ export class CodeBlockView implements NodeView { +
`; this.contentDOM = this.dom.querySelector(".content-dom"); @@ -60,6 +71,10 @@ export class CodeBlockView implements NodeView { "mousedown", this.onLanguageInputMouseDown.bind(this) ); + languageInput.addEventListener( + "input", + this.onLanguageInputTextInput.bind(this) + ); this.update(this.node); } @@ -91,6 +106,16 @@ export class CodeBlockView implements NodeView { } } + const dropdown = this.dom.querySelector(".js-language-dropdown"); + + if (dropdown instanceof HTMLUListElement) { + if (node.attrs.suggestions && node.attrs.suggestions.length > 0) { + dropdown.style.display = "block"; + } else { + dropdown.style.display = "none"; + } + } + return true; } @@ -147,6 +172,10 @@ export class CodeBlockView implements NodeView { params: target.value, isEditingLanguage: false, }); + + // Hide the dropdown + const dropdown = this.dom.querySelector(".js-language-dropdown") as HTMLUListElement; + dropdown.style.display = "none"; } private onLanguageInputKeyDown(event: KeyboardEvent) { @@ -159,4 +188,58 @@ export class CodeBlockView implements NodeView { private onLanguageInputMouseDown(event: MouseEvent) { event.stopPropagation(); } + + private onLanguageInputTextInput(event: Event) { + const input = event.target as HTMLInputElement; + const query = input.value.toLowerCase(); + const suggestions = this.availableLanguages.filter(lang => + lang.toLowerCase().includes(query) + ); + + this.updateNodeAttrs({ + suggestions: suggestions, + }); + + this.renderDropdown(suggestions); + } + + private renderDropdown(suggestions: string[]) { + const dropdown = this.dom.querySelector(".js-language-dropdown") as HTMLUListElement; + dropdown.innerHTML = ""; // Clear previous suggestions + + if (suggestions.length === 0) { + dropdown.style.display = "none"; + return; + } + + // suggestions.forEach(lang => { + // const li = document.createElement("li"); + // li.textContent = lang; + // li.style.padding = "4px 8px"; + // li.style.cursor = "pointer"; + + // li.addEventListener("mousedown", (event: MouseEvent) => { + // // Prevent blur event from closing the dropdown too early. + // event.preventDefault(); + // }); + + // li.addEventListener("click", () => { + // const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + // input.value = lang; + // // Update the language immediately + // this.updateNodeAttrs({ + // params: lang, + // isEditingLanguage: false, + // }); + // dropdown.style.display = "none"; + // // Optionally, return focus to the editor + // this.view.focus(); + // }); + + // dropdown.appendChild(li); + // }); + + dropdown.style.display = "block"; + } + } diff --git a/src/rich-text/schema.ts b/src/rich-text/schema.ts index dec3977c..7ead02d1 100644 --- a/src/rich-text/schema.ts +++ b/src/rich-text/schema.ts @@ -94,6 +94,7 @@ const nodes: { params: { default: "" }, autodetectedLanguage: { default: "" }, isEditingLanguage: { default: false }, + suggestions: { default: null }, }, parseDOM: [ { From fb25a081d814df218fa3018082dc1aeac75e6a62 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 7 Apr 2025 14:34:43 +0100 Subject: [PATCH 14/54] Render dropdown in update method instead --- src/rich-text/node-views/code-block.ts | 52 +++++++++++++------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 6885ff11..7521ec2f 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -109,8 +109,8 @@ export class CodeBlockView implements NodeView { const dropdown = this.dom.querySelector(".js-language-dropdown"); if (dropdown instanceof HTMLUListElement) { - if (node.attrs.suggestions && node.attrs.suggestions.length > 0) { - dropdown.style.display = "block"; + if (node.attrs.suggestions) { + this.renderDropdown(node.attrs.suggestions); } else { dropdown.style.display = "none"; } @@ -199,8 +199,6 @@ export class CodeBlockView implements NodeView { this.updateNodeAttrs({ suggestions: suggestions, }); - - this.renderDropdown(suggestions); } private renderDropdown(suggestions: string[]) { @@ -212,32 +210,32 @@ export class CodeBlockView implements NodeView { return; } - // suggestions.forEach(lang => { - // const li = document.createElement("li"); - // li.textContent = lang; - // li.style.padding = "4px 8px"; - // li.style.cursor = "pointer"; + for (const lang of suggestions) { + const li = document.createElement("li"); + li.textContent = lang; + li.style.padding = "4px 8px"; + li.style.cursor = "pointer"; - // li.addEventListener("mousedown", (event: MouseEvent) => { - // // Prevent blur event from closing the dropdown too early. - // event.preventDefault(); - // }); + li.addEventListener("mousedown", (event: MouseEvent) => { + // Prevent blur event from closing the dropdown too early. + event.preventDefault(); + }); - // li.addEventListener("click", () => { - // const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; - // input.value = lang; - // // Update the language immediately - // this.updateNodeAttrs({ - // params: lang, - // isEditingLanguage: false, - // }); - // dropdown.style.display = "none"; - // // Optionally, return focus to the editor - // this.view.focus(); - // }); + li.addEventListener("click", () => { + const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + input.value = lang; + // Update the language immediately + this.updateNodeAttrs({ + params: lang, + isEditingLanguage: false, + }); + dropdown.style.display = "none"; + // Optionally, return focus to the editor + this.view.focus(); + }); - // dropdown.appendChild(li); - // }); + dropdown.appendChild(li); + } dropdown.style.display = "block"; } From e412cfb8f2786609d2507c7275e5d2c3a6b9f590 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 7 Apr 2025 15:52:20 +0100 Subject: [PATCH 15/54] Tweaks --- src/rich-text/node-views/code-block.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 7521ec2f..07ba0f8a 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -171,11 +171,8 @@ export class CodeBlockView implements NodeView { this.updateNodeAttrs({ params: target.value, isEditingLanguage: false, + suggestions: null, }); - - // Hide the dropdown - const dropdown = this.dom.querySelector(".js-language-dropdown") as HTMLUListElement; - dropdown.style.display = "none"; } private onLanguageInputKeyDown(event: KeyboardEvent) { @@ -192,9 +189,9 @@ export class CodeBlockView implements NodeView { private onLanguageInputTextInput(event: Event) { const input = event.target as HTMLInputElement; const query = input.value.toLowerCase(); - const suggestions = this.availableLanguages.filter(lang => - lang.toLowerCase().includes(query) - ); + const suggestions = query.length > 0 ? this.availableLanguages.filter(lang => + lang.toLowerCase().startsWith(query) + ) : []; this.updateNodeAttrs({ suggestions: suggestions, From 58fd5ee2e10fadefed61d3067b5b454f020e1551 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 8 Apr 2025 14:40:29 +0100 Subject: [PATCH 16/54] Don't put "(auto)" in the textbox --- src/rich-text/node-views/code-block.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 07ba0f8a..b0478330 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -10,7 +10,6 @@ import { escapeHTML } from "../../shared/utils"; export class CodeBlockView implements NodeView { dom: HTMLElement | null; contentDOM?: HTMLElement | null; - private currentLanguageDisplayName: string = null; private node: ProsemirrorNode; private view: EditorView; private getPos: () => number; @@ -87,14 +86,7 @@ export class CodeBlockView implements NodeView { this.node = node; - const newLanguageDisplayName = this.getLanguageDisplayName(node); - - // If the language has changed, update the language indicator - if (newLanguageDisplayName !== this.currentLanguageDisplayName) { - this.currentLanguageDisplayName = newLanguageDisplayName; - this.dom.querySelector(".js-language-indicator").textContent = - newLanguageDisplayName; - } + this.dom.querySelector(".js-language-indicator").textContent = this.getLanguageDisplayName(); const input = this.dom.querySelector(".js-language-input"); @@ -120,8 +112,8 @@ export class CodeBlockView implements NodeView { } /** 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) { @@ -155,7 +147,7 @@ export class CodeBlockView implements NodeView { const input = this.dom.querySelector(".js-language-input"); if (input instanceof HTMLInputElement) { - input.value = this.currentLanguageDisplayName; + input.value = getBlockLanguage(this.node).Language; input.style.display = "block"; input.focus(); } From 874f6935dee99f876a939ebd831ff81ba85e15f1 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 8 Apr 2025 14:42:52 +0100 Subject: [PATCH 17/54] Actually for auto-detected, don't prepopulate it at all --- src/rich-text/node-views/code-block.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index b0478330..3e621f2d 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -147,7 +147,8 @@ export class CodeBlockView implements NodeView { const input = this.dom.querySelector(".js-language-input"); if (input instanceof HTMLInputElement) { - input.value = getBlockLanguage(this.node).Language; + const language = getBlockLanguage(this.node); + input.value = !language.IsAutoDetected ? language.Language : ""; input.style.display = "block"; input.focus(); } From 96e35c5dd8c58153f0feb94f08d11cc26dec5e0f Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 8 Apr 2025 14:53:10 +0100 Subject: [PATCH 18/54] Press Escape to cancel edit, and ignore spaces --- src/rich-text/node-views/code-block.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 3e621f2d..e82ef1b2 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -13,6 +13,7 @@ export class CodeBlockView implements NodeView { private node: ProsemirrorNode; private view: EditorView; private getPos: () => number; + private ignoreBlur: boolean = false; // Temporarily hardcoding this for now private availableLanguages = [ @@ -159,6 +160,13 @@ export class CodeBlockView implements NodeView { } private onLanguageInputBlur(event: FocusEvent) { + // If editing was cancelled via Escape, then skip updating. + if (this.ignoreBlur) { + // Reset the flag for future blur events. + this.ignoreBlur = false; + return; + } + const target = event.target as HTMLInputElement; this.updateNodeAttrs({ @@ -171,6 +179,15 @@ export class CodeBlockView implements NodeView { private onLanguageInputKeyDown(event: KeyboardEvent) { if (event.key === "Enter") { this.view.focus(); + } else if (event.key === "Escape") { + this.ignoreBlur = true; + this.updateNodeAttrs({ + isEditingLanguage: false, + suggestions: null, + }); + this.view.focus(); + } else if (event.key === " ") { + event.preventDefault(); } event.stopPropagation(); } From d182113b2453eb745b3ed69779c747420a1ae02a Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 8 Apr 2025 14:57:21 +0100 Subject: [PATCH 19/54] Limit number of suggestions --- src/rich-text/node-views/code-block.ts | 41 ++++++++++++++++---------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index e82ef1b2..883dd48f 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -23,7 +23,9 @@ export class CodeBlockView implements NodeView { "ruby", "csharp", "go", - ]; + ]; + + private maxSuggestions = 5; constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { this.node = node; @@ -87,7 +89,8 @@ export class CodeBlockView implements NodeView { this.node = node; - this.dom.querySelector(".js-language-indicator").textContent = this.getLanguageDisplayName(); + this.dom.querySelector(".js-language-indicator").textContent = + this.getLanguageDisplayName(); const input = this.dom.querySelector(".js-language-input"); @@ -166,7 +169,7 @@ export class CodeBlockView implements NodeView { this.ignoreBlur = false; return; } - + const target = event.target as HTMLInputElement; this.updateNodeAttrs({ @@ -199,37 +202,44 @@ export class CodeBlockView implements NodeView { 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) - ) : []; + const suggestions = + query.length > 0 + ? this.availableLanguages + .filter((lang) => lang.toLowerCase().startsWith(query)) + .slice(0, this.maxSuggestions) + : []; this.updateNodeAttrs({ suggestions: suggestions, }); } - + private renderDropdown(suggestions: string[]) { - const dropdown = this.dom.querySelector(".js-language-dropdown") as HTMLUListElement; + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ) as HTMLUListElement; dropdown.innerHTML = ""; // Clear previous suggestions - + if (suggestions.length === 0) { dropdown.style.display = "none"; return; } - + for (const lang of suggestions) { const li = document.createElement("li"); li.textContent = lang; li.style.padding = "4px 8px"; li.style.cursor = "pointer"; - + li.addEventListener("mousedown", (event: MouseEvent) => { // Prevent blur event from closing the dropdown too early. event.preventDefault(); }); - + li.addEventListener("click", () => { - const input = this.dom.querySelector(".js-language-input") as HTMLInputElement; + const input = this.dom.querySelector( + ".js-language-input" + ) as HTMLInputElement; input.value = lang; // Update the language immediately this.updateNodeAttrs({ @@ -240,11 +250,10 @@ export class CodeBlockView implements NodeView { // Optionally, return focus to the editor this.view.focus(); }); - + dropdown.appendChild(li); } - + dropdown.style.display = "block"; } - } From fa5405cd0f4f35cc1a36e9ecd5950d35f9c9fa7b Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 8 Apr 2025 15:01:58 +0100 Subject: [PATCH 20/54] Lint and tidy --- src/rich-text/node-views/code-block.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 883dd48f..c3546fb1 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -106,7 +106,7 @@ export class CodeBlockView implements NodeView { if (dropdown instanceof HTMLUListElement) { if (node.attrs.suggestions) { - this.renderDropdown(node.attrs.suggestions); + this.renderDropdown(node.attrs.suggestions as string[]); } else { dropdown.style.display = "none"; } @@ -215,9 +215,12 @@ export class CodeBlockView implements NodeView { } private renderDropdown(suggestions: string[]) { - const dropdown = this.dom.querySelector( - ".js-language-dropdown" - ) as HTMLUListElement; + const dropdown = this.dom.querySelector(".js-language-dropdown"); + + if (!(dropdown instanceof HTMLUListElement)) { + return; + } + dropdown.innerHTML = ""; // Clear previous suggestions if (suggestions.length === 0) { @@ -237,17 +240,18 @@ export class CodeBlockView implements NodeView { }); li.addEventListener("click", () => { - const input = this.dom.querySelector( - ".js-language-input" - ) as HTMLInputElement; + const input = this.dom.querySelector(".js-language-input"); + + if (!(input instanceof HTMLInputElement)) { + return; + } + input.value = lang; - // Update the language immediately this.updateNodeAttrs({ params: lang, isEditingLanguage: false, }); dropdown.style.display = "none"; - // Optionally, return focus to the editor this.view.focus(); }); From c757e0bb1c884bdf85da0214d3975b180755901a Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 9 Apr 2025 13:56:39 +0100 Subject: [PATCH 21/54] Move language list into options --- site/index.ts | 9 +++++++++ src/rich-text/editor.ts | 4 +++- src/rich-text/node-views/code-block.ts | 14 +++----------- 3 files changed, 15 insertions(+), 12 deletions(-) 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/editor.ts b/src/rich-text/editor.ts index d792daec..a6c72424 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -50,6 +50,8 @@ 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[]; }; } @@ -154,7 +156,7 @@ export class RichTextEditor extends BaseView { view: EditorView, getPos: () => number ) => { - return new CodeBlockView(node, view, getPos); + return new CodeBlockView(node, view, getPos, this.options.highlighting.languages); }, image( node: ProseMirrorNode, diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index c3546fb1..2924dd1b 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -13,24 +13,16 @@ export class CodeBlockView implements NodeView { private node: ProsemirrorNode; private view: EditorView; private getPos: () => number; + private availableLanguages: string[]; private ignoreBlur: boolean = false; - // Temporarily hardcoding this for now - private availableLanguages = [ - "javascript", - "java", - "python", - "ruby", - "csharp", - "go", - ]; - private maxSuggestions = 5; - constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) { + constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number, availableLanguages: string[]) { this.node = node; this.view = view; this.getPos = getPos; + this.availableLanguages = availableLanguages; this.render(); } From a2b97198c1a56c7d798e68551ba8e0ea901e220b Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 9 Apr 2025 14:09:36 +0100 Subject: [PATCH 22/54] Format --- src/rich-text/editor.ts | 7 ++++++- src/rich-text/node-views/code-block.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index a6c72424..19bbf962 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -156,7 +156,12 @@ export class RichTextEditor extends BaseView { view: EditorView, getPos: () => number ) => { - return new CodeBlockView(node, view, getPos, this.options.highlighting.languages); + return new CodeBlockView( + node, + view, + getPos, + this.options.highlighting.languages + ); }, image( node: ProseMirrorNode, diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 2924dd1b..6e07305f 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -18,7 +18,12 @@ export class CodeBlockView implements NodeView { private maxSuggestions = 5; - constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number, availableLanguages: string[]) { + constructor( + node: ProsemirrorNode, + view: EditorView, + getPos: () => number, + availableLanguages: string[] + ) { this.node = node; this.view = view; this.getPos = getPos; From 2c1cf8455437f208959e0828febb6f9e5b3a8229 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 9 Apr 2025 14:13:45 +0100 Subject: [PATCH 23/54] Max suggestions from config --- src/rich-text/editor.ts | 5 ++++- src/rich-text/node-views/code-block.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index 19bbf962..fccbf967 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -52,6 +52,8 @@ export interface RichTextOptions extends CommonViewOptions { highlightedNodeTypes?: string[]; /** Which languages appear as suggestions in the dropdown? */ languages?: string[]; + /** The maximum number of languages to show in the dropdown */ + maxSuggestions?: number; }; } @@ -160,7 +162,8 @@ export class RichTextEditor extends BaseView { node, view, getPos, - this.options.highlighting.languages + this.options.highlighting.languages, + this.options.highlighting.maxSuggestions ); }, image( diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 6e07305f..6c5a9ff0 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -14,20 +14,21 @@ export class CodeBlockView implements NodeView { private view: EditorView; private getPos: () => number; private availableLanguages: string[]; + private maxSuggestions: number; private ignoreBlur: boolean = false; - private maxSuggestions = 5; - constructor( node: ProsemirrorNode, view: EditorView, getPos: () => number, - availableLanguages: string[] + availableLanguages: string[], + maxSuggestions: number = 5 ) { this.node = node; this.view = view; this.getPos = getPos; this.availableLanguages = availableLanguages; + this.maxSuggestions = maxSuggestions; this.render(); } From 73c4482d3369447cf1c99933c2310a57632e91e2 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 11 Apr 2025 11:10:16 +0100 Subject: [PATCH 24/54] Implementing design --- src/rich-text/node-views/code-block.ts | 38 ++++++++++++++++---------- src/styles/icons.css | 4 +++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 6c5a9ff0..ab7349ca 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -36,11 +36,17 @@ export class CodeBlockView implements NodeView { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML` - - +
`; @@ -58,20 +64,20 @@ export class CodeBlockView implements NodeView { this.onLanguageSelectorMouseDown.bind(this) ); - const languageInput = this.dom.querySelector(".js-language-input"); - languageInput.addEventListener( + const textbox = this.dom.querySelector(".js-language-input-textbox"); + textbox.addEventListener( "blur", this.onLanguageInputBlur.bind(this) ); - languageInput.addEventListener( + textbox.addEventListener( "keydown", this.onLanguageInputKeyDown.bind(this) ); - languageInput.addEventListener( + textbox.addEventListener( "mousedown", this.onLanguageInputMouseDown.bind(this) ); - languageInput.addEventListener( + textbox.addEventListener( "input", this.onLanguageInputTextInput.bind(this) ); @@ -92,7 +98,7 @@ export class CodeBlockView implements NodeView { const input = this.dom.querySelector(".js-language-input"); - if (input instanceof HTMLInputElement) { + if (input instanceof HTMLDivElement) { if (node.attrs.isEditingLanguage) { input.style.display = "block"; } else { @@ -147,12 +153,14 @@ export class CodeBlockView implements NodeView { }); const input = this.dom.querySelector(".js-language-input"); + const textbox = this.dom.querySelector(".js-language-input-textbox"); - if (input instanceof HTMLInputElement) { - const language = getBlockLanguage(this.node); - input.value = !language.IsAutoDetected ? language.Language : ""; + if (input instanceof HTMLDivElement && textbox instanceof HTMLInputElement) { input.style.display = "block"; - input.focus(); + + const language = getBlockLanguage(this.node); + textbox.value = !language.IsAutoDetected ? language.Language : ""; + textbox.focus(); } } @@ -238,13 +246,13 @@ export class CodeBlockView implements NodeView { }); li.addEventListener("click", () => { - const input = this.dom.querySelector(".js-language-input"); + const textbox = this.dom.querySelector(".js-language-input-textbox"); - if (!(input instanceof HTMLInputElement)) { + if (!(textbox instanceof HTMLInputElement)) { return; } - input.value = lang; + textbox.value = lang; this.updateNodeAttrs({ params: lang, isEditingLanguage: false, diff --git a/src/styles/icons.css b/src/styles/icons.css index b0589e88..f00133b2 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -17,3 +17,7 @@ width: 21px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowDownSm.svg"); } +.svg-icon-bg.iconSearchSm { + width: 21px; + --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/SearchSm.svg"); +} \ No newline at end of file From fe0d96f449988f9ee31e2d815be102949e08d9fd Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 11 Apr 2025 11:37:06 +0100 Subject: [PATCH 25/54] Move the list inside a container --- src/rich-text/node-views/code-block.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index ab7349ca..a8309fcd 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -47,7 +47,9 @@ export class CodeBlockView implements NodeView { - +
`; this.contentDOM = this.dom.querySelector(".content-dom"); @@ -106,13 +108,13 @@ export class CodeBlockView implements NodeView { } } - const dropdown = this.dom.querySelector(".js-language-dropdown"); + const dropdownContainer = this.dom.querySelector(".js-language-dropdown-container"); - if (dropdown instanceof HTMLUListElement) { + if (dropdownContainer instanceof HTMLDivElement) { if (node.attrs.suggestions) { this.renderDropdown(node.attrs.suggestions as string[]); } else { - dropdown.style.display = "none"; + dropdownContainer.style.display = "none"; } } @@ -221,16 +223,17 @@ export class CodeBlockView implements NodeView { } private renderDropdown(suggestions: string[]) { + const dropdownContainer = this.dom.querySelector(".js-language-dropdown-container"); const dropdown = this.dom.querySelector(".js-language-dropdown"); - if (!(dropdown instanceof HTMLUListElement)) { + if (!(dropdown instanceof HTMLUListElement) || !(dropdownContainer instanceof HTMLDivElement)) { return; } dropdown.innerHTML = ""; // Clear previous suggestions if (suggestions.length === 0) { - dropdown.style.display = "none"; + dropdownContainer.style.display = "none"; return; } @@ -257,13 +260,13 @@ export class CodeBlockView implements NodeView { params: lang, isEditingLanguage: false, }); - dropdown.style.display = "none"; + dropdownContainer.style.display = "none"; this.view.focus(); }); dropdown.appendChild(li); } - dropdown.style.display = "block"; + dropdownContainer.style.display = "block"; } } From 0e18743337ecbe421a4cf94da37fd1f6d4020312 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 14 Apr 2025 13:47:53 +0100 Subject: [PATCH 26/54] More like design --- src/rich-text/node-views/code-block.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index a8309fcd..5c850a5b 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -41,14 +41,14 @@ export class CodeBlockView implements NodeView { - @@ -240,6 +240,7 @@ export class CodeBlockView implements NodeView { for (const lang of suggestions) { const li = document.createElement("li"); li.textContent = lang; + li.classList.add("h:bg-black-150", "px4"); li.addEventListener("mousedown", (event: MouseEvent) => { // Prevent blur event from closing the dropdown too early. From 24db7144a90ae10be9eb731ee41a59f16babbc7c Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 14 Apr 2025 14:39:15 +0100 Subject: [PATCH 28/54] Keyboard navigation --- src/rich-text/node-views/code-block.ts | 163 ++++++++++++++++++++----- 1 file changed, 133 insertions(+), 30 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 48e2221a..cc0c52ad 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -16,6 +16,8 @@ export class CodeBlockView implements NodeView { private availableLanguages: string[]; private maxSuggestions: number; private ignoreBlur: boolean = false; + // Track the currently selected suggestion index for keyboard navigation. + private selectedSuggestionIndex: number = -1; constructor( node: ProsemirrorNode, @@ -67,10 +69,7 @@ export class CodeBlockView implements NodeView { ); const textbox = this.dom.querySelector(".js-language-input-textbox"); - textbox.addEventListener( - "blur", - this.onLanguageInputBlur.bind(this) - ); + textbox.addEventListener("blur", this.onLanguageInputBlur.bind(this)); textbox.addEventListener( "keydown", this.onLanguageInputKeyDown.bind(this) @@ -99,17 +98,15 @@ export class CodeBlockView implements NodeView { this.getLanguageDisplayName(); const input = this.dom.querySelector(".js-language-input"); - if (input instanceof HTMLDivElement) { - if (node.attrs.isEditingLanguage) { - input.style.display = "block"; - } else { - input.style.display = "none"; - } + input.style.display = node.attrs.isEditingLanguage + ? "block" + : "none"; } - const dropdownContainer = this.dom.querySelector(".js-language-dropdown-container"); - + const dropdownContainer = this.dom.querySelector( + ".js-language-dropdown-container" + ); if (dropdownContainer instanceof HTMLDivElement) { if (node.attrs.suggestions) { this.renderDropdown(node.attrs.suggestions as string[]); @@ -149,17 +146,18 @@ export class CodeBlockView implements NodeView { private onLanguageSelectorClick(event: MouseEvent) { event.stopPropagation(); - this.updateNodeAttrs({ isEditingLanguage: true, }); - const input = this.dom.querySelector(".js-language-input"); const textbox = this.dom.querySelector(".js-language-input-textbox"); - if (input instanceof HTMLDivElement && textbox instanceof HTMLInputElement) { + if ( + input instanceof HTMLDivElement && + textbox instanceof HTMLInputElement + ) { input.style.display = "block"; - + const language = getBlockLanguage(this.node); textbox.value = !language.IsAutoDetected ? language.Language : ""; textbox.focus(); @@ -173,13 +171,24 @@ export class CodeBlockView implements NodeView { private onLanguageInputBlur(event: FocusEvent) { // If editing was cancelled via Escape, then skip updating. if (this.ignoreBlur) { - // Reset the flag for future blur events. this.ignoreBlur = false; return; } - const target = event.target as HTMLInputElement; + // Check if the new focused element (if any) is inside the language input container. + const container = this.dom.querySelector(".js-language-input"); + if ( + event.relatedTarget && + container && + container.contains(event.relatedTarget as Node) + ) { + // If the new focus is within the container (for example, one of the list items), + // do not update and close the dropdown. + return; + } + // Otherwise, proceed as usual. + const target = event.target as HTMLInputElement; this.updateNodeAttrs({ params: target.value, isEditingLanguage: false, @@ -188,7 +197,18 @@ export class CodeBlockView implements NodeView { } private onLanguageInputKeyDown(event: KeyboardEvent) { + const dropdown = this.dom.querySelector(".js-language-dropdown"); if (event.key === "Enter") { + // If an item is focused in the dropdown, select it. + if (dropdown) { + const activeItem = dropdown.querySelector("li:focus"); + if (activeItem) { + event.preventDefault(); + (activeItem as HTMLElement).click(); + return; + } + } + // Otherwise, simply blur and refocus the editor. this.view.focus(); } else if (event.key === "Escape") { this.ignoreBlur = true; @@ -197,6 +217,46 @@ export class CodeBlockView implements NodeView { suggestions: null, }); this.view.focus(); + } else if (event.key === "ArrowDown") { + // Navigate down into the suggestion list. + if (dropdown) { + const liElements = dropdown.querySelectorAll("li"); + if (liElements.length > 0) { + // If none is selected yet, focus the first. + if ( + this.selectedSuggestionIndex < 0 || + this.selectedSuggestionIndex >= liElements.length - 1 + ) { + this.selectedSuggestionIndex = 0; + } else { + this.selectedSuggestionIndex++; + } + ( + liElements[this.selectedSuggestionIndex] as HTMLElement + ).focus(); + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } else if (event.key === "ArrowUp") { + // Navigate up in the suggestion list. + if (dropdown) { + const liElements = dropdown.querySelectorAll("li"); + if (liElements.length > 0) { + if (this.selectedSuggestionIndex <= 0) { + this.selectedSuggestionIndex = liElements.length - 1; + } else { + this.selectedSuggestionIndex--; + } + ( + liElements[this.selectedSuggestionIndex] as HTMLElement + ).focus(); + event.preventDefault(); + event.stopPropagation(); + return; + } + } } else if (event.key === " ") { event.preventDefault(); } @@ -216,17 +276,24 @@ export class CodeBlockView implements NodeView { .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, }); } + // Updated renderDropdown method with focusable
  • items and keyboard events. private renderDropdown(suggestions: string[]) { - const dropdownContainer = this.dom.querySelector(".js-language-dropdown-container"); + const dropdownContainer = this.dom.querySelector( + ".js-language-dropdown-container" + ); const dropdown = this.dom.querySelector(".js-language-dropdown"); - if (!(dropdown instanceof HTMLUListElement) || !(dropdownContainer instanceof HTMLDivElement)) { + if ( + !(dropdown instanceof HTMLUListElement) || + !(dropdownContainer instanceof HTMLDivElement) + ) { return; } @@ -234,37 +301,73 @@ export class CodeBlockView implements NodeView { if (suggestions.length === 0) { dropdownContainer.style.display = "none"; + this.selectedSuggestionIndex = -1; return; } - for (const lang of suggestions) { + // Reset the current selection. + this.selectedSuggestionIndex = -1; + + suggestions.forEach((lang, index) => { const li = document.createElement("li"); li.textContent = lang; li.classList.add("h:bg-black-150", "px4"); - + li.tabIndex = 0; // Make it focusable via Tab + // Prevent the blur event from closing the dropdown too early. li.addEventListener("mousedown", (event: MouseEvent) => { - // Prevent blur event from closing the dropdown too early. event.preventDefault(); }); - + // When a list item is clicked, update the language. li.addEventListener("click", () => { - const textbox = this.dom.querySelector(".js-language-input-textbox"); - + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ); if (!(textbox instanceof HTMLInputElement)) { return; } - textbox.value = lang; this.updateNodeAttrs({ params: lang, isEditingLanguage: false, + suggestions: null, }); dropdownContainer.style.display = "none"; this.view.focus(); }); - + // Listen for keyboard events on each list item. + li.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + li.click(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + // Focus the next suggestion, if available. + const next = li.nextElementSibling; + if (next instanceof HTMLElement) { + next.focus(); + this.selectedSuggestionIndex = index + 1; + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + // Focus the previous suggestion, or return focus to the textbox if at the top. + const prev = li.previousElementSibling; + if (prev instanceof HTMLElement) { + prev.focus(); + this.selectedSuggestionIndex = index - 1; + } else { + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ) as HTMLElement; + if (textbox) { + textbox.focus(); + this.selectedSuggestionIndex = -1; + } + } + } + event.stopPropagation(); + }); dropdown.appendChild(li); - } + }); dropdownContainer.style.display = "block"; } From 5de5368c221683316a665db4a5fcfb8f4873b457 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 15 Apr 2025 13:48:58 +0100 Subject: [PATCH 29/54] Add keyboard shortcut to open dropdown --- src/rich-text/commands/index.ts | 28 ++++++++++++++++++++++++++ src/rich-text/key-bindings.ts | 2 ++ src/rich-text/node-views/code-block.ts | 23 +++++++++------------ src/styles/icons.css | 2 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index d762fa7f..943c9331 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -507,3 +507,31 @@ export function splitCodeBlockAtStartOfDoc( return splitBlock(state, dispatch); } + +function isSelectionInCodeBlock( + state: EditorState +): { pos: number; node: any } | null { + const { $from } = state.selection; + if ($from.parent.type.name === "code_block") { + return { pos: $from.before(), node: $from.parent }; + } + return null; +} + +// Command to open the language dropdown. +export function openCodeBlockLanguagePicker( + state: EditorState, + dispatch: (tr: Transaction) => void +) { + const codeBlock = isSelectionInCodeBlock(state); + if (!codeBlock) { + return false; + } + const { pos, node } = codeBlock; + // Update the node attributes to trigger the language input. + const newAttrs = { ...node.attrs, isEditingLanguage: true }; + if (dispatch) { + dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); + } + return true; +} 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 cc0c52ad..89f93813 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -98,10 +98,18 @@ export class CodeBlockView implements NodeView { this.getLanguageDisplayName(); const input = this.dom.querySelector(".js-language-input"); - if (input instanceof HTMLDivElement) { + const textbox = this.dom.querySelector(".js-language-input-textbox"); + if ( + input instanceof HTMLDivElement && + textbox instanceof HTMLInputElement + ) { input.style.display = node.attrs.isEditingLanguage ? "block" : "none"; + + if (node.attrs.isEditingLanguage) { + textbox.focus(); + } } const dropdownContainer = this.dom.querySelector( @@ -149,19 +157,6 @@ export class CodeBlockView implements NodeView { this.updateNodeAttrs({ isEditingLanguage: true, }); - const input = this.dom.querySelector(".js-language-input"); - const textbox = this.dom.querySelector(".js-language-input-textbox"); - - if ( - input instanceof HTMLDivElement && - textbox instanceof HTMLInputElement - ) { - input.style.display = "block"; - - const language = getBlockLanguage(this.node); - textbox.value = !language.IsAutoDetected ? language.Language : ""; - textbox.focus(); - } } private onLanguageSelectorMouseDown(event: MouseEvent) { diff --git a/src/styles/icons.css b/src/styles/icons.css index f00133b2..94ffcb4b 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -20,4 +20,4 @@ .svg-icon-bg.iconSearchSm { width: 21px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/SearchSm.svg"); -} \ No newline at end of file +} From 213f3edd93eafcf50cdfc96a8f2b314b485e151e Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 16 Apr 2025 09:01:31 +0100 Subject: [PATCH 30/54] Updated contrast --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 89f93813..0dd7c180 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -38,7 +38,7 @@ export class CodeBlockView implements NodeView { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML` - From 4812c6f8abbb4d530f3bd2ffb5940c92c687f885 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 16 Apr 2025 14:34:29 +0100 Subject: [PATCH 31/54] Lint --- src/rich-text/commands/index.ts | 10 ++++++++-- src/rich-text/node-views/code-block.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 943c9331..1a25952f 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -4,7 +4,13 @@ import { toggleMark, wrapIn, } from "prosemirror-commands"; -import { Mark, MarkType, NodeType, Schema } from "prosemirror-model"; +import { + Mark, + MarkType, + NodeType, + Schema, + Node as ProsemirrorNode, +} from "prosemirror-model"; import { Command, EditorState, @@ -510,7 +516,7 @@ export function splitCodeBlockAtStartOfDoc( function isSelectionInCodeBlock( state: EditorState -): { pos: number; node: any } | null { +): { pos: number; node: ProsemirrorNode } | null { const { $from } = state.selection; if ($from.parent.type.name === "code_block") { return { pos: $from.before(), node: $from.parent }; diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 0dd7c180..be5787c0 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -352,8 +352,8 @@ export class CodeBlockView implements NodeView { } else { const textbox = this.dom.querySelector( ".js-language-input-textbox" - ) as HTMLElement; - if (textbox) { + ); + if (textbox instanceof HTMLInputElement) { textbox.focus(); this.selectedSuggestionIndex = -1; } From dda97d18d949cdce8803d19e1741983c9a075a33 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 16 Apr 2025 14:43:31 +0100 Subject: [PATCH 32/54] Move code block commands into code-block.ts --- src/rich-text/commands/code-block.ts | 28 ++++++++++++++++++++++ src/rich-text/commands/index.ts | 36 +--------------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/src/rich-text/commands/code-block.ts b/src/rich-text/commands/code-block.ts index 89f13ad5..9d51a150 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; +} + +// Command to open the language dropdown. +export function openCodeBlockLanguagePicker( + state: EditorState, + dispatch: (tr: Transaction) => void +) { + const codeBlock = isSelectionInCodeBlock(state); + if (!codeBlock) { + return false; + } + const { pos, node } = codeBlock; + // Update the node attributes to trigger the language input. + const newAttrs = { ...node.attrs, isEditingLanguage: true }; + if (dispatch) { + dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); + } + return true; +} diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 1a25952f..d762fa7f 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -4,13 +4,7 @@ import { toggleMark, wrapIn, } from "prosemirror-commands"; -import { - Mark, - MarkType, - NodeType, - Schema, - Node as ProsemirrorNode, -} from "prosemirror-model"; +import { Mark, MarkType, NodeType, Schema } from "prosemirror-model"; import { Command, EditorState, @@ -513,31 +507,3 @@ export function splitCodeBlockAtStartOfDoc( return splitBlock(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; -} - -// Command to open the language dropdown. -export function openCodeBlockLanguagePicker( - state: EditorState, - dispatch: (tr: Transaction) => void -) { - const codeBlock = isSelectionInCodeBlock(state); - if (!codeBlock) { - return false; - } - const { pos, node } = codeBlock; - // Update the node attributes to trigger the language input. - const newAttrs = { ...node.attrs, isEditingLanguage: true }; - if (dispatch) { - dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); - } - return true; -} From 6b2669e33f63bc05655f55bb6495a6a88227d506 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 16 Apr 2025 14:54:30 +0100 Subject: [PATCH 33/54] Tidy --- src/rich-text/commands/code-block.ts | 4 ++-- src/rich-text/node-views/code-block.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/rich-text/commands/code-block.ts b/src/rich-text/commands/code-block.ts index 9d51a150..d2eae54d 100644 --- a/src/rich-text/commands/code-block.ts +++ b/src/rich-text/commands/code-block.ts @@ -369,7 +369,6 @@ function isSelectionInCodeBlock( return null; } -// Command to open the language dropdown. export function openCodeBlockLanguagePicker( state: EditorState, dispatch: (tr: Transaction) => void @@ -379,7 +378,8 @@ export function openCodeBlockLanguagePicker( return false; } const { pos, node } = codeBlock; - // Update the node attributes to trigger the language input. + + // Setting isEditingLanguage to true will open the language picker const newAttrs = { ...node.attrs, isEditingLanguage: true }; if (dispatch) { dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs)); diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index be5787c0..8023e95f 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -16,7 +16,6 @@ export class CodeBlockView implements NodeView { private availableLanguages: string[]; private maxSuggestions: number; private ignoreBlur: boolean = false; - // Track the currently selected suggestion index for keyboard navigation. private selectedSuggestionIndex: number = -1; constructor( @@ -38,7 +37,7 @@ export class CodeBlockView implements NodeView { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML` - From 0eba96f470be709135434d1a7bde6db6fb912f03 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 16 Apr 2025 14:56:37 +0100 Subject: [PATCH 34/54] Tidy --- src/rich-text/node-views/code-block.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 8023e95f..1907b171 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -41,13 +41,13 @@ export class CodeBlockView implements NodeView { -
  • items and keyboard events. private renderDropdown(suggestions: string[]) { const dropdownContainer = this.dom.querySelector( ".js-language-dropdown-container" @@ -305,7 +305,7 @@ export class CodeBlockView implements NodeView { return; } - dropdown.innerHTML = ""; // Clear previous suggestions + dropdown.innerHTML = ""; if (suggestions.length === 0) { dropdownContainer.style.display = "none"; @@ -322,7 +322,7 @@ export class CodeBlockView implements NodeView { li.classList.add("h:bg-black-150", "px4"); li.tabIndex = 0; // Make it focusable - // Prevent the blur event from closing the dropdown too early. + // 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(); }); From 71eae1ca00823eb46a3590d58c33445783f5f83c Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Thu, 17 Apr 2025 14:59:05 +0100 Subject: [PATCH 38/54] Lint --- src/rich-text/node-views/code-block.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 84e18d91..585763af 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -204,7 +204,7 @@ export class CodeBlockView implements NodeView { // Otherwise, blur and refocus the editor. This will trigger onLanguageInputBlur to update the language. this.view.focus(); } else if (event.key === "Escape") { - this.onEscape(event); + this.onEscape(); } else if (event.key === "ArrowDown") { this.onArrowDown(event); } else if (event.key === "ArrowUp") { @@ -217,7 +217,7 @@ export class CodeBlockView implements NodeView { event.stopPropagation(); } - private onEscape(event: KeyboardEvent) { + private onEscape() { this.ignoreBlur = true; this.updateNodeAttrs({ isEditingLanguage: false, @@ -231,7 +231,7 @@ export class CodeBlockView implements NodeView { event.preventDefault(); event.stopPropagation(); } - + private onArrowDown(event: KeyboardEvent) { this.updateSelectedSuggestionIndex(1); event.preventDefault(); @@ -316,7 +316,7 @@ export class CodeBlockView implements NodeView { // Reset the current selection. this.selectedSuggestionIndex = -1; - suggestions.forEach((lang, index) => { + suggestions.forEach((lang) => { const li = document.createElement("li"); li.textContent = lang; li.classList.add("h:bg-black-150", "px4"); @@ -351,7 +351,7 @@ export class CodeBlockView implements NodeView { event.stopPropagation(); li.click(); } else if (event.key === "Escape") { - this.onEscape(event); + this.onEscape(); } else if (event.key === "ArrowDown") { this.onArrowDown(event); } else if (event.key === "ArrowUp") { From 1f2768445815b543ade44fa1e01beeb9612886fa Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 11:43:23 +0100 Subject: [PATCH 39/54] Create gold-wombats-jog.md --- .changeset/gold-wombats-jog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gold-wombats-jog.md 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 From 559ae99adef6d04af5d1c1d054746fa0e8b85199 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 11:49:25 +0100 Subject: [PATCH 40/54] Fix tests --- src/rich-text/editor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index fccbf967..57e84195 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -162,8 +162,8 @@ export class RichTextEditor extends BaseView { node, view, getPos, - this.options.highlighting.languages, - this.options.highlighting.maxSuggestions + this.options.highlighting?.languages || [], + this.options.highlighting?.maxSuggestions ); }, image( From 748741ce9927239d2bdd3f9bdecc6bd94923dda7 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 12:07:49 +0100 Subject: [PATCH 41/54] Add some unit tests --- test/rich-text/commands/code-block.test.ts | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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(); + }); +}); From 29435eacb6654b79a189954132684ad528d0cb51 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 13:51:47 +0100 Subject: [PATCH 42/54] Add tests --- test/rich-text/node-views/code-block.test.ts | 159 +++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts index 0559784a..9bbbc66c 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; @@ -35,3 +53,144 @@ console.log("Hello World"); }); }); }); + +describe("code-block language picker", () => { + let richText: RichTextEditor; + + beforeEach(() => { + richText = new RichTextEditor( + document.createElement("div"), + "", + externalPluginProvider([testCodeBlockPlugin]) + ); + // seed with a JS codeblock + richText.content = `~~~js +console.log("Hello"); +~~~`; + }); + + it("toggles the language‐input panel when the selector button is clicked", () => { + 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", () => { + // 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", () => { + // 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"); + }); + + it("commits whatever you typed if you blur without selecting a suggestion", () => { + // 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", () => { + // 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"); + }); +}); From 31156dfbcb0e237165e4a8213b8ca2d1c8cec58d Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 14:07:13 +0100 Subject: [PATCH 43/54] Lowercase languages in tests --- test/rich-text/node-views/code-block.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts index 9bbbc66c..40c5679d 100644 --- a/test/rich-text/node-views/code-block.test.ts +++ b/test/rich-text/node-views/code-block.test.ts @@ -5,7 +5,7 @@ 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 languages = ["javascript", "python", "ruby"]; const testCodeBlockPlugin: EditorPlugin = () => ({ richText: { @@ -106,14 +106,14 @@ console.log("Hello"); // model should get the suggestions array const codeNode = richText.editorView.state.doc.firstChild; - expect(codeNode.attrs.suggestions).toEqual(["Python"]); + 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"); + expect(items[0].textContent).toBe("python"); }); it("sets the language on clicking a suggestion", () => { @@ -133,9 +133,9 @@ console.log("Hello"); ); suggestion.click(); - // model should have updated params → "Ruby" and closed the panel + // 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.params).toBe("ruby"); expect(codeNode.attrs.isEditingLanguage).toBe(false); const inputPanel = @@ -146,21 +146,21 @@ console.log("Hello"); }); it("commits whatever you typed if you blur without selecting a suggestion", () => { - // open and type "TypeScript" + // 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"; + textbox.value = "typescript"; // blur the textbox (simulate losing focus) textbox.dispatchEvent(new FocusEvent("blur", { bubbles: true })); - // the code_block should now have params = "TypeScript" + // the code_block should now have params = "typescript" const codeNode = richText.editorView.state.doc.firstChild; - expect(codeNode.attrs.params).toBe("TypeScript"); + expect(codeNode.attrs.params).toBe("typescript"); expect(codeNode.attrs.isEditingLanguage).toBe(false); // suggestions should be cleared expect(codeNode.attrs.suggestions).toBeNull(); From fc82ec1012d38c7a5f7a3f115f93c6ace52e52e7 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 14:10:04 +0100 Subject: [PATCH 44/54] Use generic querySelector instead of instanceof --- src/rich-text/node-views/code-block.ts | 74 ++++++++++---------------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 585763af..7f6e6068 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -96,30 +96,26 @@ export class CodeBlockView implements NodeView { 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 ( - input instanceof HTMLDivElement && - textbox instanceof HTMLInputElement - ) { - input.style.display = node.attrs.isEditingLanguage - ? "block" - : "none"; + const input = + this.dom.querySelector(".js-language-input"); + const textbox = this.dom.querySelector( + ".js-language-input-textbox" + ); - if (node.attrs.isEditingLanguage) { - textbox.focus(); - } + input.style.display = node.attrs.isEditingLanguage ? "block" : "none"; + + if (node.attrs.isEditingLanguage) { + textbox.focus(); } - const dropdownContainer = this.dom.querySelector( + const dropdownContainer = this.dom.querySelector( ".js-language-dropdown-container" ); - if (dropdownContainer instanceof HTMLDivElement) { - if (node.attrs.suggestions) { - this.renderDropdown(node.attrs.suggestions as string[]); - } else { - dropdownContainer.style.display = "none"; - } + + if (node.attrs.suggestions) { + this.renderDropdown(node.attrs.suggestions as string[]); + } else { + dropdownContainer.style.display = "none"; } return true; @@ -189,10 +185,9 @@ export class CodeBlockView implements NodeView { } private onLanguageInputKeyDown(event: KeyboardEvent) { - const dropdown = this.dom.querySelector(".js-language-dropdown"); - if (!(dropdown instanceof HTMLUListElement)) { - return; - } + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ); if (event.key === "Enter") { // If an item is focused in the dropdown, select it. const activeItem = dropdown.querySelector("li:focus"); @@ -240,10 +235,9 @@ export class CodeBlockView implements NodeView { // 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"); - if (!(dropdown instanceof HTMLUListElement)) { - return; - } + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ); const liElements = dropdown.querySelectorAll("li"); if (liElements.length == 0) { @@ -260,13 +254,11 @@ export class CodeBlockView implements NodeView { } if (this.selectedSuggestionIndex == -1) { - const textbox = this.dom.querySelector( + const textbox = this.dom.querySelector( ".js-language-input-textbox" ); - if (textbox instanceof HTMLInputElement) { - textbox.focus(); - this.selectedSuggestionIndex = -1; - } + textbox.focus(); + this.selectedSuggestionIndex = -1; } else { (liElements[this.selectedSuggestionIndex] as HTMLElement).focus(); } @@ -293,17 +285,12 @@ export class CodeBlockView implements NodeView { } private renderDropdown(suggestions: string[]) { - const dropdownContainer = this.dom.querySelector( + const dropdownContainer = this.dom.querySelector( ".js-language-dropdown-container" ); - const dropdown = this.dom.querySelector(".js-language-dropdown"); - - if ( - !(dropdown instanceof HTMLUListElement) || - !(dropdownContainer instanceof HTMLDivElement) - ) { - return; - } + const dropdown = this.dom.querySelector( + ".js-language-dropdown" + ); dropdown.innerHTML = ""; @@ -329,12 +316,9 @@ export class CodeBlockView implements NodeView { // When a list item is clicked, update the language. li.addEventListener("click", () => { - const textbox = this.dom.querySelector( + const textbox = this.dom.querySelector( ".js-language-input-textbox" ); - if (!(textbox instanceof HTMLInputElement)) { - return; - } textbox.value = lang; this.updateNodeAttrs({ params: lang, From 0c6089e0a1861e7ea701ab278ebc00f11adb8585 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 17:25:42 +0100 Subject: [PATCH 45/54] Move the initial codeblock content into the tests --- test/rich-text/node-views/code-block.test.ts | 47 ++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts index 40c5679d..7707005f 100644 --- a/test/rich-text/node-views/code-block.test.ts +++ b/test/rich-text/node-views/code-block.test.ts @@ -30,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"); ~~~`; @@ -52,6 +52,26 @@ 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", () => { @@ -63,13 +83,13 @@ describe("code-block language picker", () => { "", externalPluginProvider([testCodeBlockPlugin]) ); - // seed with a JS codeblock + }); + + it("toggles the language input panel when the selector button is clicked", () => { richText.content = `~~~js console.log("Hello"); ~~~`; - }); - it("toggles the language‐input panel when the selector button is clicked", () => { const button = richText.editorView.dom.querySelector( "button.js-language-selector" ); @@ -92,6 +112,10 @@ console.log("Hello"); }); 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") @@ -117,6 +141,10 @@ console.log("Hello"); }); 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") @@ -143,9 +171,16 @@ console.log("Hello"); ".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") @@ -167,6 +202,10 @@ console.log("Hello"); }); it("cancels editing and closes on Escape key", () => { + richText.content = `~~~js +console.log("Hello"); +~~~`; + // open panel richText.editorView.dom .querySelector("button.js-language-selector") From 506ca6d4138ea4e47444a18c937a35febb1e8af0 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Tue, 22 Apr 2025 18:15:17 +0100 Subject: [PATCH 46/54] Turn indented blocks into fenced ones when setting language NOTE this only works in tests at the moment, need to figure out adding the plugin to the real editor --- .../plugins/fenced-code-serializer.ts | 54 +++++++++++++++++++ src/shared/editor-plugin.ts | 2 +- test/rich-text/node-views/code-block.test.ts | 39 +++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/rich-text/plugins/fenced-code-serializer.ts diff --git a/src/rich-text/plugins/fenced-code-serializer.ts b/src/rich-text/plugins/fenced-code-serializer.ts new file mode 100644 index 00000000..5748e0bb --- /dev/null +++ b/src/rich-text/plugins/fenced-code-serializer.ts @@ -0,0 +1,54 @@ +import { MarkdownSerializerState } from "prosemirror-markdown"; +import { Node as PMNode } from "prosemirror-model"; +import { defaultMarkdownSerializer } from "prosemirror-markdown"; +import { EditorPlugin, EditorPluginSpec } from "../../shared/editor-plugin"; + +export const fencedCodeSerializer: EditorPlugin = (): EditorPluginSpec => + ({ + markdown: { + serializers: { + nodes: { + code_block(state: MarkdownSerializerState, node: PMNode) { + const { params, markup } = node.attrs as { + params: string; + markup?: string | null; + }; + + // helper: was this really a fence? + const isFence = (m: string) => + m.startsWith("`") || m.startsWith("~"); + + if (params) { + // user picked a language + // only reuse old marker if it was a real fence, else default to backticks + const marker = isFence(markup || "") + ? markup + : "```"; + state.write(marker + params + "\n"); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write(marker); + state.closeBlock(node); + } else if (isFence(markup || "")) { + // untouched fence (no language) + state.write(markup + "\n"); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write(markup); + state.closeBlock(node); + } else { + // pure indent-only block + node.textContent + .split("\n") + .forEach((line, i, arr) => { + state.write(" " + line); + if (i < arr.length - 1) state.write("\n"); + }); + state.closeBlock(node); + } + }, + }, + marks: defaultMarkdownSerializer.marks, + }, + }, + }) as unknown as EditorPluginSpec; diff --git a/src/shared/editor-plugin.ts b/src/shared/editor-plugin.ts index fbdd4319..ae8309f4 100644 --- a/src/shared/editor-plugin.ts +++ b/src/shared/editor-plugin.ts @@ -40,7 +40,7 @@ type AddMenuItemsCallback = ( ) => MenuBlock[]; /** Describes the properties that can be used for extending commonmark support in the editor */ -type MarkdownExtensionProps = { +export type MarkdownExtensionProps = { /** * Parsers for prosemirror-markdown * @see {@type import("prosemirror-markdown").MarkdownParser["tokens"]} diff --git a/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts index 7707005f..6a3d0926 100644 --- a/test/rich-text/node-views/code-block.test.ts +++ b/test/rich-text/node-views/code-block.test.ts @@ -4,6 +4,7 @@ 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"; +import { fencedCodeSerializer } from "../../../src/rich-text/plugins/fenced-code-serializer"; const languages = ["javascript", "python", "ruby"]; @@ -81,7 +82,7 @@ describe("code-block language picker", () => { richText = new RichTextEditor( document.createElement("div"), "", - externalPluginProvider([testCodeBlockPlugin]) + externalPluginProvider([testCodeBlockPlugin, fencedCodeSerializer]) ); }); @@ -232,4 +233,40 @@ console.log("Hello"); ); 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") + ); + }); }); From a460dfcf7bfdecdc6c794fb2cb3857e5d4d6230a Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 09:41:46 +0100 Subject: [PATCH 47/54] Better way of turning indented blocks into fenced ones when setting language --- .../plugins/fenced-code-serializer.ts | 54 ------------------- src/shared/editor-plugin.ts | 2 +- src/shared/markdown-serializer.ts | 7 ++- test/rich-text/node-views/code-block.test.ts | 3 +- 4 files changed, 8 insertions(+), 58 deletions(-) delete mode 100644 src/rich-text/plugins/fenced-code-serializer.ts diff --git a/src/rich-text/plugins/fenced-code-serializer.ts b/src/rich-text/plugins/fenced-code-serializer.ts deleted file mode 100644 index 5748e0bb..00000000 --- a/src/rich-text/plugins/fenced-code-serializer.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MarkdownSerializerState } from "prosemirror-markdown"; -import { Node as PMNode } from "prosemirror-model"; -import { defaultMarkdownSerializer } from "prosemirror-markdown"; -import { EditorPlugin, EditorPluginSpec } from "../../shared/editor-plugin"; - -export const fencedCodeSerializer: EditorPlugin = (): EditorPluginSpec => - ({ - markdown: { - serializers: { - nodes: { - code_block(state: MarkdownSerializerState, node: PMNode) { - const { params, markup } = node.attrs as { - params: string; - markup?: string | null; - }; - - // helper: was this really a fence? - const isFence = (m: string) => - m.startsWith("`") || m.startsWith("~"); - - if (params) { - // user picked a language - // only reuse old marker if it was a real fence, else default to backticks - const marker = isFence(markup || "") - ? markup - : "```"; - state.write(marker + params + "\n"); - state.text(node.textContent, false); - state.ensureNewLine(); - state.write(marker); - state.closeBlock(node); - } else if (isFence(markup || "")) { - // untouched fence (no language) - state.write(markup + "\n"); - state.text(node.textContent, false); - state.ensureNewLine(); - state.write(markup); - state.closeBlock(node); - } else { - // pure indent-only block - node.textContent - .split("\n") - .forEach((line, i, arr) => { - state.write(" " + line); - if (i < arr.length - 1) state.write("\n"); - }); - state.closeBlock(node); - } - }, - }, - marks: defaultMarkdownSerializer.marks, - }, - }, - }) as unknown as EditorPluginSpec; diff --git a/src/shared/editor-plugin.ts b/src/shared/editor-plugin.ts index ae8309f4..fbdd4319 100644 --- a/src/shared/editor-plugin.ts +++ b/src/shared/editor-plugin.ts @@ -40,7 +40,7 @@ type AddMenuItemsCallback = ( ) => MenuBlock[]; /** Describes the properties that can be used for extending commonmark support in the editor */ -export type MarkdownExtensionProps = { +type MarkdownExtensionProps = { /** * Parsers for prosemirror-markdown * @see {@type import("prosemirror-markdown").MarkdownParser["tokens"]} 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/test/rich-text/node-views/code-block.test.ts b/test/rich-text/node-views/code-block.test.ts index 6a3d0926..3e984478 100644 --- a/test/rich-text/node-views/code-block.test.ts +++ b/test/rich-text/node-views/code-block.test.ts @@ -4,7 +4,6 @@ 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"; -import { fencedCodeSerializer } from "../../../src/rich-text/plugins/fenced-code-serializer"; const languages = ["javascript", "python", "ruby"]; @@ -82,7 +81,7 @@ describe("code-block language picker", () => { richText = new RichTextEditor( document.createElement("div"), "", - externalPluginProvider([testCodeBlockPlugin, fencedCodeSerializer]) + externalPluginProvider([testCodeBlockPlugin]) ); }); From 0915b06ca57c5fe41dc6f60947ffd2cf10b61bc8 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 10:00:59 +0100 Subject: [PATCH 48/54] Show language list with a popover z-index --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 7f6e6068..e59da564 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -47,7 +47,7 @@ export class CodeBlockView implements NodeView { -
    +
      From 6792593daab25aa30fb7832435704395c13a47ae Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 10:15:01 +0100 Subject: [PATCH 49/54] Use .s-btn classes for the button styling --- src/rich-text/node-views/code-block.ts | 5 ++--- src/styles/icons.css | 4 ---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index e59da564..ee838b34 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -37,11 +37,10 @@ export class CodeBlockView implements NodeView { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML` - -
      +
      diff --git a/src/styles/icons.css b/src/styles/icons.css index 94ffcb4b..589b1eb7 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -13,10 +13,6 @@ .svg-icon-bg.iconEllipsisHorizontal { --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EllipsisHorizontal.svg"); } -.svg-icon-bg.iconArrowDownSm { - width: 21px; - --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/ArrowDownSm.svg"); -} .svg-icon-bg.iconSearchSm { width: 21px; --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/SearchSm.svg"); From d1028037b346f6848abc1325ec47a5645db8df5d Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 10:16:19 +0100 Subject: [PATCH 50/54] Add descriptive text to the button --- src/rich-text/node-views/code-block.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index ee838b34..ab839956 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -38,6 +38,7 @@ export class CodeBlockView implements NodeView { this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML`
      From dc60fd6226a257c971a43676d580ee8e78fe6050 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 10:17:57 +0100 Subject: [PATCH 51/54] Button is a button --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index ab839956..0b4cdb02 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -37,7 +37,7 @@ export class CodeBlockView implements NodeView { this.dom = document.createElement("div"); this.dom.classList.add("ps-relative", "p0", "ws-normal", "ow-normal"); this.dom.innerHTML = escapeHTML` - From 9aedf6ad79b21a06f1ae16b5b31000d14209fb50 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Wed, 23 Apr 2025 10:19:47 +0100 Subject: [PATCH 52/54] Add appropriate id to input, update label --- src/rich-text/node-views/code-block.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 0b4cdb02..fb6e5570 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -43,8 +43,8 @@ export class CodeBlockView implements NodeView {
      - - + +
      From 84028377f40d9ee30620d30daf04b0c296ac689e Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 28 Apr 2025 11:31:48 +0100 Subject: [PATCH 53/54] Add a comment --- src/rich-text/node-views/code-block.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index fb6e5570..2afbc754 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -265,6 +265,7 @@ export class CodeBlockView implements NodeView { } private onLanguageInputMouseDown(event: MouseEvent) { + // this prevents ProseMirror freaking out when triple-clicking the textbox event.stopPropagation(); } From 83592f14afb03f389ee9e8476e9621b87134b209 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Mon, 28 Apr 2025 11:35:11 +0100 Subject: [PATCH 54/54] Fix Enter in textbox adding a newline to the editor --- src/rich-text/node-views/code-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/node-views/code-block.ts b/src/rich-text/node-views/code-block.ts index 2afbc754..44bcdd11 100644 --- a/src/rich-text/node-views/code-block.ts +++ b/src/rich-text/node-views/code-block.ts @@ -189,10 +189,10 @@ export class CodeBlockView implements NodeView { ".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) { - event.preventDefault(); (activeItem as HTMLElement).click(); return; }