From dc7d5a8cf85d0d8171c547bd3a0177e39ddc71ec Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Fri, 17 Jan 2025 15:42:41 -0300 Subject: [PATCH] DEV: autocomplete/emoji improvements --- .../discourse/app/lib/autocomplete.js | 9 +- .../components/prosemirror-editor.gjs | 12 +- .../static/prosemirror/extensions/emoji.js | 1 - .../static/prosemirror/extensions/hashtag.js | 1 - .../static/prosemirror/extensions/mention.js | 1 - ...ster-additional.js => register-default.js} | 0 .../prosemirror/lib/text-manipulation.js | 123 +++++++++--------- pnpm-lock.yaml | 3 + 8 files changed, 80 insertions(+), 70 deletions(-) rename app/assets/javascripts/discourse/app/static/prosemirror/extensions/{register-additional.js => register-default.js} (100%) diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 4c9a1537d13..06be460365f 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -581,7 +581,14 @@ export default function (options) { let term = options.textHandler .getValue() .substring(completeStart + (options.key ? 1 : 0), cp); - updateAutoComplete(dataSource(term, options)); + if ( + !options.key || + options.textHandler.getValue()[completeStart] === options.key + ) { + updateAutoComplete(dataSource(term, options)); + } else { + closeAutocomplete(); + } } } diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs index fcf532917d6..a6e17fb26a4 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs +++ b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs @@ -6,7 +6,7 @@ import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import { next } from "@ember/runloop"; import { service } from "@ember/service"; -import "../extensions/register-additional"; // registers all non-core extensions +import "../extensions/register-default"; import { baseKeymap } from "prosemirror-commands"; import * as ProsemirrorCommands from "prosemirror-commands"; import { dropCursor } from "prosemirror-dropcursor"; @@ -32,6 +32,8 @@ import placeholder from "../extensions/placeholder"; import * as utils from "../lib/plugin-utils"; import TextManipulation from "../lib/text-manipulation"; +const AUTOCOMPLETE_KEY_DOWN_SUPPRESS = ["Enter", "Tab"]; + /** * @typedef PluginContext * @property {string} placeholder @@ -167,6 +169,7 @@ export default class ProsemirrorEditor extends Component { this.view = new EditorView(container, { convertFromMarkdown: this.convertFromMarkdown, + convertToMarkdown: this.serializer.convert.bind(this.serializer), getContext: params.getContext, nodeViews: extractNodeViews(this.extensions), state, @@ -176,7 +179,7 @@ export default class ProsemirrorEditor extends Component { this.view.updateState(this.view.state.apply(tr)); if (tr.docChanged && tr.getMeta("addToHistory") !== false) { - // TODO(renato): avoid calling this on every change + // If this gets expensive, we can debounce it const value = this.serializer.convert(this.view.state.doc); this.#lastSerialized = value; this.args.change?.({ target: { value } }); @@ -193,9 +196,10 @@ export default class ProsemirrorEditor extends Component { }, }, handleKeyDown: (view, event) => { - // skip the event if it's an Enter keypress and the autocomplete is open + // suppress if Enter/Tab and the autocomplete is open return ( - event.key === "Enter" && !!document.querySelector(".autocomplete") + AUTOCOMPLETE_KEY_DOWN_SUPPRESS.includes(event.key) && + !!document.querySelector(".autocomplete") ); }, }); diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/emoji.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/emoji.js index f5a0e9881f8..47015f98ee2 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/emoji.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/emoji.js @@ -38,7 +38,6 @@ const extension = { }, ]; }, - leafText: (node) => `:${node.attrs.code}:`, }, }, diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/hashtag.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/hashtag.js index 8a8cb02409e..bc3dcbbbec0 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/hashtag.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/hashtag.js @@ -27,7 +27,6 @@ const extension = { `#${node.attrs.name}`, ]; }, - leafText: (node) => `#${node.attrs.name}`, }, }, diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/mention.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/mention.js index 7f558ac4f40..dea23221905 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/mention.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/mention.js @@ -28,7 +28,6 @@ const extension = { `@${node.attrs.name}`, ]; }, - leafText: (node) => `@${node.attrs.name}`, }, }, diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-additional.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js similarity index 100% rename from app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-additional.js rename to app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/lib/text-manipulation.js b/app/assets/javascripts/discourse/app/static/prosemirror/lib/text-manipulation.js index 626d13a4cbe..97f50fdc13f 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/lib/text-manipulation.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/lib/text-manipulation.js @@ -32,7 +32,7 @@ export default class ProsemirrorTextManipulation { }); } - getSelected(trimLeading, opts) { + getSelected() { const start = this.view.state.selection.from; const end = this.view.state.selection.to; const value = this.view.state.doc.textBetween(start, end, " ", " "); @@ -70,7 +70,7 @@ export default class ProsemirrorTextManipulation { this.applySurround(this.getSelected(), head, tail, exampleKey, opts); } - applySurround(sel, head, tail, exampleKey, opts) { + applySurround(sel, head, tail, exampleKey) { const applySurroundMap = { italic_text: this.schema.marks.em, bold_text: this.schema.marks.strong, @@ -94,7 +94,7 @@ export default class ProsemirrorTextManipulation { ); } - addText(sel, text, options) { + addText(sel, text) { const doc = this.view.props.convertFromMarkdown(text); // assumes it returns a single block node @@ -123,12 +123,7 @@ export default class ProsemirrorTextManipulation { this.focus(); } - applyList(_selection, head, exampleKey, opts) { - // This is similar to applySurround, but doing it line by line - // We may use markdown parsing as a fallback if we don't identify the exampleKey - // similarly to applySurround - // TODO to check actual applyList uses in the wild - + applyList(_selection, head, exampleKey) { let command; const isInside = (type) => { @@ -143,27 +138,18 @@ export default class ProsemirrorTextManipulation { }; if (exampleKey === "list_item") { - if (head === "* ") { - command = isInside(this.schema.nodes.bullet_list) - ? lift - : wrapIn(this.schema.nodes.bullet_list); - } else { - command = isInside(this.schema.nodes.ordered_list) - ? lift - : wrapIn(this.schema.nodes.ordered_list); - } - } else { - const applyListMap = { - blockquote_text: this.schema.nodes.blockquote, - }; + const nodeType = + head === "* " + ? this.schema.nodes.bullet_list + : this.schema.nodes.ordered_list; - if (applyListMap[exampleKey]) { - command = isInside(applyListMap[exampleKey]) - ? lift - : wrapIn(applyListMap[exampleKey]); - } else { - // TODO(renato): fallback to markdown parsing - } + command = isInside(this.schema.nodes.list_item) ? lift : wrapIn(nodeType); + } else if (exampleKey === "blockquote_text") { + command = isInside(this.schema.nodes.blockquote) + ? lift + : wrapIn(this.schema.nodes.blockquote); + } else { + throw new Error("Unknown exampleKey"); } command?.(this.view.state, this.view.dispatch); @@ -201,7 +187,7 @@ export default class ProsemirrorTextManipulation { paste() { // Intentionally no-op // Pasting markdown is being handled by the markdown-paste extension - // Pasting an url on top of a text is being handled by the link extension + // Pasting a url on top of a text is being handled by the link extension } selectText(from, length, opts) { @@ -224,17 +210,6 @@ export default class ProsemirrorTextManipulation { return this.autocompleteHandler.inCodeBlock(); } - /** - * Gets the textual caret position within the selected text block - * - * @returns {number} - */ - getCaretPosition() { - const { $anchor } = this.view.state.selection; - - return $anchor.pos - $anchor.start(); - } - indentSelection(direction) { const { selection } = this.view.state; @@ -264,11 +239,50 @@ export default class ProsemirrorTextManipulation { this.focus(); } - replaceText(oldValue, newValue, opts) { - // this method should be deprecated, this is not very reliable: - // we're converting the current document to markdown, replacing it, and setting its result - // as the new document content - // TODO + replaceText(oldValue, newValue, opts = {}) { + const markdown = this.view.props.convertToMarkdown(this.view.state.doc); + + const regex = opts.regex || new RegExp(oldValue, "g"); + const index = opts.index || 0; + let matchCount = 0; + + const newMarkdown = markdown.replace(regex, (match) => { + if (matchCount++ === index) { + return newValue; + } + return match; + }); + + if (markdown === newMarkdown) { + return; + } + + const newDoc = this.view.props.convertFromMarkdown(newMarkdown); + if (!newDoc) { + return; + } + + const diff = newValue.length - oldValue.length; + const startOffset = this.view.state.selection.from + diff; + const endOffset = this.view.state.selection.to + diff; + + const tr = this.view.state.tr.replaceWith( + 0, + this.view.state.doc.content.size, + newDoc.content + ); + + if ( + !opts.skipNewSelection && + (opts.forceFocus || this.view.dom === document.activeElement) + ) { + const adjustedStart = Math.min(startOffset, tr.doc.content.size); + const adjustedEnd = Math.min(endOffset, tr.doc.content.size); + + tr.setSelection(TextSelection.create(tr.doc, adjustedStart, adjustedEnd)); + } + + this.view.dispatch(tr); } toggleDirection() { @@ -313,21 +327,6 @@ class ProsemirrorAutocompleteHandler { const from = this.view.state.selection.from - node.nodeSize + start; const to = this.view.state.selection.from - node.nodeSize + end + 1; - // Alternative approach using inputRules, if `convertFromMarkdown` is too expensive - // - // let replaced; - // for (const plugin of this.view.state.plugins) { - // if (plugin.spec.isInputRules) { - // replaced ||= plugin.props.handleTextInput(this.view, from, to, term, null); - // } - // } - // - // if (!replaced) { - // this.view.dispatch( - // this.view.state.tr.replaceWith(from, to, this.schema.text(term)) - // ); - // } - const doc = this.view.props.convertFromMarkdown(term); const tr = this.view.state.tr.replaceWith( @@ -449,7 +448,7 @@ class ProsemirrorPlaceholderHandler { return true; }); - // keeping compatibility with plugins that change the image node via markdown + // keeping compatibility with plugins that change the upload markdown const doc = this.view.props.convertFromMarkdown(markdown); this.view.dispatch( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf0936bae55..9ff7681b5b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,9 @@ importers: morphlex: specifier: ^0.0.16 version: 0.0.16 + orderedmap: + specifier: ^2.1.1 + version: 2.1.1 pretty-text: specifier: workspace:1.0.0 version: link:../pretty-text