DEV: refactor composer-editor/d-editor, a little more (#29973)

Adds setupEditor to ComposerEditor so it can setup/destroy events when the underlying editorComponent is switched.

Moves putCursorAtEnd uses (which implementation is textarea-specific) to TextareaTextManipulation.

Moves insertCurrentTime and a corresponding test, which is discourse-local-dates specific, to the plugin.

Moves applyList and formatCode from DEditor to the TextareaTextManipulation.

Moves DEditor._applySurround to TextareaTextManipulation.applySurroundSelection

Avoids resetting the textarea value on applyList and formatCode, keeping the undo history.
This commit is contained in:
Renato Atilio 2024-12-02 18:24:14 -03:00 committed by GitHub
parent 706987ce76
commit 85691a7f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 216 additions and 168 deletions

View File

@ -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}}
</DEditor>

View File

@ -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);

View File

@ -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,

View File

@ -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({

View File

@ -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);
}

View File

@ -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({

View File

@ -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`<DEditor @value={{this.value}} />`);
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";

View File

@ -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 (

View File

@ -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"
);
});
});