diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.hbs b/app/assets/javascripts/discourse/app/components/composer-editor.hbs index 8b3b64d974b..a3ee48a7e35 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-editor.hbs @@ -43,6 +43,7 @@ @outletArgs={{hash composer=this.composer.model editorType="composer"}} @topicId={{this.composer.model.topic.id}} @categoryId={{this.composer.model.category.id}} + @onSetup={{this.setupEditor}} > {{yield}} diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index d2fc19d9bac..dafc514b632 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -18,7 +18,6 @@ import { linkSeenMentions, } from "discourse/lib/link-mentions"; import { loadOneboxes } from "discourse/lib/load-oneboxes"; -import putCursorAtEnd from "discourse/lib/put-cursor-at-end"; import { authorizesOneOrMoreImageExtensions, IMAGE_MARKDOWN_REGEX, @@ -139,7 +138,7 @@ export default class ComposerEditor extends Component { @observes("composer.focusTarget") setFocus() { if (this.composer.focusTarget === "editor") { - putCursorAtEnd(this.element.querySelector("textarea")); + this.textManipulation.putCursorAtEnd(); } } @@ -188,21 +187,9 @@ export default class ComposerEditor extends Component { @on("didInsertElement") _composerEditorInit() { - const input = this.element.querySelector(".d-editor-input"); const preview = this.element.querySelector(".d-editor-preview-wrapper"); - - input?.addEventListener( - "scroll", - this._throttledSyncEditorAndPreviewScroll - ); - this._registerImageAltTextButtonClick(preview); - // Focus on the body unless we have a title - if (!this.get("composer.model.canEditTitle")) { - putCursorAtEnd(input); - } - if (this.composer.allowUpload) { this.uppyComposerUpload.setup(this.element); } @@ -210,6 +197,30 @@ export default class ComposerEditor extends Component { this.appEvents.trigger(`${this.composerEventPrefix}:will-open`); } + @bind + setupEditor(textManipulation) { + this.textManipulation = textManipulation; + + const input = this.element.querySelector(".d-editor-input"); + + input?.addEventListener( + "scroll", + this._throttledSyncEditorAndPreviewScroll + ); + + // Focus on the body unless we have a title + if (!this.get("composer.model.canEditTitle")) { + this.textManipulation.putCursorAtEnd(); + } + + return () => { + input?.removeEventListener( + "scroll", + this._throttledSyncEditorAndPreviewScroll + ); + }; + } + @discourseComputed( "composer.model.reply", "composer.model.replyLength", @@ -785,7 +796,6 @@ export default class ComposerEditor extends Component { @on("willDestroyElement") _composerClosed() { - const input = this.element.querySelector(".d-editor-input"); const preview = this.element.querySelector(".d-editor-preview-wrapper"); if (this.composer.allowUpload) { @@ -802,11 +812,6 @@ export default class ComposerEditor extends Component { ); }); - input?.removeEventListener( - "scroll", - this._throttledSyncEditorAndPreviewScroll - ); - preview?.removeEventListener("click", this._handleAltTextCancelButtonClick); preview?.removeEventListener("click", this._handleAltTextEditButtonClick); preview?.removeEventListener("click", this._handleAltTextOkButtonClick); diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 9df4bca8af9..771c9bef2a8 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -21,7 +21,6 @@ import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts"; import { linkSeenMentions } from "discourse/lib/link-mentions"; import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; -import { getHead } from "discourse/lib/textarea-text-manipulation"; import userSearch from "discourse/lib/user-search"; import { destroyUserStatuses, @@ -36,8 +35,6 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates"; import discourseComputed, { bind } from "discourse-common/utils/decorators"; import { i18n } from "discourse-i18n"; -const FOUR_SPACES_INDENT = "4-spaces-indent"; - let _createCallbacks = []; export function addToolbarCallback(func) { @@ -145,8 +142,6 @@ export default class DEditor extends Component { keymap["tab"] = () => this.textManipulation.indentSelection("right"); keymap["shift+tab"] = () => this.textManipulation.indentSelection("left"); - keymap[`${PLATFORM_KEY_MODIFIER}+shift+.`] = () => - this.send("insertCurrentTime"); return keymap; } @@ -485,34 +480,6 @@ export default class DEditor extends Component { }); } - _applyList(sel, head, exampleKey, opts) { - if (sel.value.includes("\n")) { - this.textManipulation.applySurround(sel, head, "", exampleKey, opts); - } else { - const [hval, hlen] = getHead(head); - if (sel.start === sel.end) { - sel.value = i18n(`composer.${exampleKey}`); - } - - const trimmedPre = sel.pre.trim(); - const number = sel.value.startsWith(hval) - ? sel.value.slice(hlen) - : `${hval}${sel.value}`; - const preLines = trimmedPre.length ? `${trimmedPre}\n\n` : ""; - - const trimmedPost = sel.post.trim(); - const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost; - - this.set("value", `${preLines}${number}${post}`); - this.textManipulation.selectText(preLines.length, number.length); - } - } - - _applySurround(head, tail, exampleKey, opts) { - const selected = this.textManipulation.getSelected(); - this.textManipulation.applySurround(selected, head, tail, exampleKey, opts); - } - @action rovingButtonBar(event) { let target = event.target; @@ -559,6 +526,27 @@ export default class DEditor extends Component { } } + /** + * Represents a toolbar event object passed to toolbar buttons. + * + * @typedef {Object} ToolbarEvent + * @property {function} applySurround - Applies surrounding text + * @property {function} formatCode - Formats as code + * @property {function} replaceText - Replaces text + * @property {function} selectText - Selects a range of text + * @property {function} toggleDirection - Toggles text direction + * @property {function} getText - Gets the text + * @property {function} addText - Adds text + * @property {function} applyList - Applies a list format + * @property {*} selected - The current selection + */ + + /** + * Creates a new toolbar event object + * + * @param {boolean} trimLeading - Whether to trim leading whitespace + * @returns {ToolbarEvent} An object with toolbar event actions + */ newToolbarEvent(trimLeading) { const selected = this.textManipulation.getSelected(trimLeading); return { @@ -574,8 +562,8 @@ export default class DEditor extends Component { opts ), applyList: (head, exampleKey, opts) => - this._applyList(selected, head, exampleKey, opts), - formatCode: (...args) => this.send("formatCode", args), + this.textManipulation.applyList(selected, head, exampleKey, opts), + formatCode: () => this.textManipulation.formatCode(), addText: (text) => this.textManipulation.addText(selected, text), getText: () => this.value, toggleDirection: () => this.textManipulation.toggleDirection(), @@ -628,71 +616,6 @@ export default class DEditor extends Component { }); } - @action - formatCode() { - if (this.disabled) { - return; - } - - const sel = this.textManipulation.getSelected("", { lineVal: true }); - const selValue = sel.value; - const hasNewLine = selValue.includes("\n"); - const isBlankLine = sel.lineVal.trim().length === 0; - const isFourSpacesIndent = - this.siteSettings.code_formatting_style === FOUR_SPACES_INDENT; - - if (!hasNewLine) { - if (selValue.length === 0 && isBlankLine) { - if (isFourSpacesIndent) { - const example = i18n(`composer.code_text`); - this.set("value", `${sel.pre} ${example}${sel.post}`); - return this.textManipulation.selectText( - sel.pre.length + 4, - example.length - ); - } else { - return this.textManipulation.applySurround( - sel, - "```\n", - "\n```", - "paste_code_text" - ); - } - } else { - return this.textManipulation.applySurround(sel, "`", "`", "code_title"); - } - } else { - if (isFourSpacesIndent) { - return this.textManipulation.applySurround( - sel, - " ", - "", - "code_text" - ); - } else { - const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : ""; - const postNewline = sel.post[0] !== "\n" ? "\n" : ""; - return this.textManipulation.addText( - sel, - `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` - ); - } - } - } - - @action - insertCurrentTime() { - const sel = this.textManipulation.getSelected("", { lineVal: true }); - const timezone = this.currentUser.user_option.timezone; - const time = moment().format("HH:mm:ss"); - const date = moment().format("YYYY-MM-DD"); - - this.textManipulation.addText( - sel, - `[date=${date} time=${time} timezone="${timezone}"]` - ); - } - @action handleFocusIn() { this.set("isEditorFocused", true); @@ -715,6 +638,8 @@ export default class DEditor extends Component { this._applyHashtagAutocomplete(); this._applyMentionAutocomplete(); + const destroyEditor = this.onSetup?.(textManipulation); + scheduleOnce("afterRender", this, this._readyNow); return () => { @@ -723,6 +648,8 @@ export default class DEditor extends Component { this.element?.removeEventListener("paste", textManipulation.paste); textManipulation.autocomplete("destroy"); + + destroyEditor?.(); }; } @@ -741,7 +668,11 @@ export default class DEditor extends Component { textManipulation, "replaceText" ); - this.appEvents.on("composer:apply-surround", this, "_applySurround"); + this.appEvents.on( + "composer:apply-surround", + textManipulation, + "applySurroundSelection" + ); this.appEvents.on( "composer:indent-selected-text", textManipulation, @@ -764,7 +695,11 @@ export default class DEditor extends Component { textManipulation, "replaceText" ); - this.appEvents.off("composer:apply-surround", this, "_applySurround"); + this.appEvents.off( + "composer:apply-surround", + textManipulation, + "applySurroundSelection" + ); this.appEvents.off( "composer:indent-selected-text", textManipulation, diff --git a/app/assets/javascripts/discourse/app/lib/composer/toolbar.js b/app/assets/javascripts/discourse/app/lib/composer/toolbar.js index 9e7d34f5d7d..878aadb8a7c 100644 --- a/app/assets/javascripts/discourse/app/lib/composer/toolbar.js +++ b/app/assets/javascripts/discourse/app/lib/composer/toolbar.js @@ -78,7 +78,7 @@ export default class Toolbar { icon: "code", preventFocus: true, trimLeading: true, - action: (...args) => this.context.send("formatCode", args), + perform: (e) => e.formatCode(), }); this.addButton({ diff --git a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js index 2c5f4ceb07a..6206624a42d 100644 --- a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js @@ -3,6 +3,7 @@ import { next, schedule } from "@ember/runloop"; import { service } from "@ember/service"; import { isEmpty } from "@ember/utils"; import $ from "jquery"; +import putCursorAtEnd from "discourse/lib/put-cursor-at-end"; import { generateLinkifyFunction } from "discourse/lib/text"; import { siteDir } from "discourse/lib/text-direction"; import toMarkdown from "discourse/lib/to-markdown"; @@ -28,6 +29,8 @@ const OP = { ADDED: 2, }; +const FOUR_SPACES_INDENT = "4-spaces-indent"; + // Our head can be a static string or a function that returns a string // based on input (like for numbered lists). export function getHead(head, prev) { @@ -42,6 +45,7 @@ export default class TextareaTextManipulation { @service appEvents; @service siteSettings; @service capabilities; + @service currentUser; eventPrefix; textarea; @@ -180,6 +184,10 @@ export default class TextareaTextManipulation { } } + applySurroundSelection(head, tail, exampleKey, opts) { + this.applySurround(this.getSelected(), head, tail, exampleKey, opts); + } + applySurround(sel, head, tail, exampleKey, opts) { const pre = sel.pre; const post = sel.post; @@ -730,6 +738,7 @@ export default class TextareaTextManipulation { } } + @bind async inCodeBlock() { return inCodeBlock( this.$textarea.value ?? this.$textarea.val(), @@ -737,6 +746,7 @@ export default class TextareaTextManipulation { ); } + @bind toggleDirection() { let currentDir = this.$textarea.attr("dir") ? this.$textarea.attr("dir") @@ -746,6 +756,75 @@ export default class TextareaTextManipulation { this.$textarea.attr("dir", newDir).focus(); } + @bind + applyList(sel, head, exampleKey, opts) { + if (sel.value.includes("\n")) { + this.applySurround(sel, head, "", exampleKey, opts); + } else { + const [hval, hlen] = getHead(head); + if (sel.start === sel.end) { + sel.value = i18n(`composer.${exampleKey}`); + } + + const number = sel.value.startsWith(hval) + ? sel.value.slice(hlen) + : `${hval}${sel.value}`; + + const preNewlines = sel.pre.trim() && "\n\n"; + const postNewlines = sel.post.trim() && "\n\n"; + + const textToInsert = `${preNewlines}${number}${postNewlines}`; + + const preChars = sel.pre.length - sel.pre.trimEnd().length; + const postChars = sel.post.length - sel.post.trimStart().length; + + this._insertAt(sel.start - preChars, sel.end + postChars, textToInsert); + this.selectText( + sel.start + (preNewlines.length - preChars), + number.length + ); + } + } + + @bind + formatCode() { + const sel = this.getSelected("", { lineVal: true }); + const selValue = sel.value; + const hasNewLine = selValue.includes("\n"); + const isBlankLine = sel.lineVal.trim().length === 0; + const isFourSpacesIndent = + this.siteSettings.code_formatting_style === FOUR_SPACES_INDENT; + + if (!hasNewLine) { + if (selValue.length === 0 && isBlankLine) { + if (isFourSpacesIndent) { + const example = i18n(`composer.code_text`); + this._insertAt(sel.start, sel.end, ` ${example}`); + return this.selectText(sel.pre.length + 4, example.length); + } else { + return this.applySurround(sel, "```\n", "\n```", "paste_code_text"); + } + } else { + return this.applySurround(sel, "`", "`", "code_title"); + } + } else { + if (isFourSpacesIndent) { + return this.applySurround(sel, " ", "", "code_text"); + } else { + const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : ""; + const postNewline = sel.post[0] !== "\n" ? "\n" : ""; + return this.addText( + sel, + `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` + ); + } + } + } + + putCursorAtEnd() { + putCursorAtEnd(this.textarea); + } + autocomplete() { return this.$textarea.autocomplete(...arguments); } diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index 1be36484550..c8d4233c25b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -1316,32 +1316,6 @@ acceptance("Composer - default category not set", function (needs) { }); // END: Default Composer Category tests -acceptance("Composer - current time", function (needs) { - needs.user(); - - test("composer insert current time shortcut", async function (assert) { - await visit("/t/internationalization-localization/280"); - - await click("#topic-footer-buttons .btn.create"); - assert.dom(".d-editor-input").exists("the composer input is visible"); - await fillIn(".d-editor-input", "and the time now is: "); - - const date = moment().format("YYYY-MM-DD"); - - await triggerKeyEvent(".d-editor-input", "keydown", ".", { - ...metaModifier, - shiftKey: true, - }); - - assert.true( - query("#reply-control .d-editor-input") - .value.trim() - .startsWith(`and the time now is: [date=${date}`), - "adds the current date" - ); - }); -}); - acceptance("composer buttons API", function (needs) { needs.user(); needs.settings({ diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js index 35fec4d4277..3343d7cdaf6 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js @@ -344,6 +344,26 @@ third line` assert.strictEqual(textarea.selectionEnd, 23); }); + test("code button does not reset undo history", async function (assert) { + this.set("value", "existing"); + + await render(hbs``); + const textarea = query("textarea.d-editor-input"); + textarea.selectionStart = 0; + textarea.selectionEnd = 8; + + await click("button.code"); + assert.strictEqual(this.value, "`existing`"); + + await click("button.code"); + assert.strictEqual(this.value, "existing"); + + document.execCommand("undo"); + assert.strictEqual(this.value, "`existing`"); + document.execCommand("undo"); + assert.strictEqual(this.value, "existing"); + }); + test("code fences", async function (assert) { this.set("value", ""); @@ -615,6 +635,22 @@ third line` assert.strictEqual(textarea.selectionEnd, 18); }); + testCase( + "list button does not reset undo history", + async function (assert, textarea) { + this.set("value", "existing"); + textarea.selectionStart = 0; + textarea.selectionEnd = 8; + + await click("button.list"); + assert.strictEqual(this.value, "1. existing"); + + document.execCommand("undo"); + + assert.strictEqual(this.value, "existing"); + } + ); + test("clicking the toggle-direction changes dir from ltr to rtl and back", async function (assert) { this.siteSettings.support_mixed_text_direction = true; this.siteSettings.default_locale = "en"; diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js index a4bff342cf3..41fafecf706 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js @@ -142,6 +142,7 @@ function _partitionedRanges(element) { } function initializeDiscourseLocalDates(api) { + const modal = api.container.lookup("service:modal"); const siteSettings = api.container.lookup("service:site-settings"); const defaultTitle = i18n("discourse_local_dates.default_title", { site_name: siteSettings.title, @@ -164,25 +165,19 @@ function initializeDiscourseLocalDates(api) { id: "local-dates", group: "extras", icon: "calendar-days", - sendAction: (event) => - toolbar.context.send("insertDiscourseLocalDate", event), - }); - }); + perform: (event) => + modal.show(LocalDatesCreateModal, { + model: { insertDate: (markup) => event.addText(markup) }, + }), + shortcut: "Shift+.", + shortcutAction: (event) => { + const timezone = api.getCurrentUser().user_option.timezone; + const time = moment().format("HH:mm:ss"); + const date = moment().format("YYYY-MM-DD"); - api.modifyClass("component:d-editor", { - modal: service(), - pluginId: "discourse-local-dates", - actions: { - insertDiscourseLocalDate(toolbarEvent) { - this.modal.show(LocalDatesCreateModal, { - model: { - insertDate: (markup) => { - toolbarEvent.addText(markup); - }, - }, - }); + event.addText(`[date=${date} time=${time} timezone="${timezone}"]`); }, - }, + }); }); addTextDecorateCallback(function ( diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js index 2a4a0c5fdbf..8a2c4cc958b 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js @@ -1,6 +1,9 @@ -import { click, fillIn, visit } from "@ember/test-helpers"; +import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { test } from "qunit"; -import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + metaModifier, +} from "discourse/tests/helpers/qunit-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; acceptance("Local Dates - composer", function (needs) { @@ -128,4 +131,24 @@ acceptance("Local Dates - composer", function (needs) { await click("ul.formats a.moment-format"); assert.dom("input.format-input").hasValue("LLL"); }); + + test("composer insert current time shortcut", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("#topic-footer-buttons .btn.create"); + await fillIn(".d-editor-input", "and the time now is: "); + + await triggerKeyEvent(".d-editor-input", "keydown", ".", { + ...metaModifier, + shiftKey: true, + }); + + const date = moment().format("YYYY-MM-DD"); + + assert + .dom("#reply-control .d-editor-input") + .hasValue( + new RegExp(`and the time now is: \\[date=${date}`), + "it adds the current date" + ); + }); });