DEV: Remove jquery from textarea-manipulation, improve undo handling (#17050)
This commit removes many uses of `this._$textarea`, and also switches us to use `document.execCommand("insertText")` for the majority of manipulations. This means that the browser undo history will be preserved when doing things like pasting rich html, using bold/italic shortcuts, etc. These manipulations are already extensively tested. This commit extends a few of the tests to verify the undo behavior. There are still a few cases (e.g. replacing upload placeholders with true URLs) where we don't necessarily want to bring the composer into focus. In those cases, the old history-breaking behavior remains for now.
This commit is contained in:
parent
c054a47d9a
commit
75d9c16156
|
@ -112,13 +112,12 @@ export default Mixin.create({
|
|||
|
||||
this._textarea.selectionStart = from;
|
||||
this._textarea.selectionEnd = from + length;
|
||||
this._$textarea.trigger("change");
|
||||
if (opts.scroll) {
|
||||
const oldScrollPos = this._$textarea.scrollTop();
|
||||
const oldScrollPos = this._textarea.scrollTop;
|
||||
if (!this.capabilities.isIOS) {
|
||||
this._$textarea.focus();
|
||||
this._textarea.focus();
|
||||
}
|
||||
this._$textarea.scrollTop(oldScrollPos);
|
||||
this._textarea.scrollTop = oldScrollPos;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
@ -178,7 +177,7 @@ export default Mixin.create({
|
|||
|
||||
const [hval, hlen] = getHead(head);
|
||||
const example = I18n.t(`composer.${exampleKey}`);
|
||||
this.set("value", `${pre}${hval}${example}${tail}${post}`);
|
||||
this._insertAt(sel.start, sel.end, `${hval}${example}${tail}`);
|
||||
this.selectText(pre.length + hlen, example.length);
|
||||
} else if (opts && !opts.multiline) {
|
||||
let [hval, hlen] = getHead(head);
|
||||
|
@ -190,13 +189,11 @@ export default Mixin.create({
|
|||
}
|
||||
|
||||
if (pre.slice(-hlen) === hval && post.slice(0, tail.length) === tail) {
|
||||
this.set(
|
||||
"value",
|
||||
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tail.length)}`
|
||||
);
|
||||
// Already wrapped in the surround. Remove it.
|
||||
this._insertAt(sel.start - hlen, sel.end + tail.length, sel.value);
|
||||
this.selectText(sel.start - hlen, sel.value.length);
|
||||
} else {
|
||||
this.set("value", `${pre}${hval}${sel.value}${tail}${post}`);
|
||||
this._insertAt(sel.start, sel.end, `${hval}${sel.value}${tail}`);
|
||||
this.selectText(sel.start + hlen, sel.value.length);
|
||||
}
|
||||
} else {
|
||||
|
@ -208,10 +205,8 @@ export default Mixin.create({
|
|||
pre.slice(-tlen) === tail &&
|
||||
post.slice(0, hlen) === hval
|
||||
) {
|
||||
this.set(
|
||||
"value",
|
||||
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tlen)}`
|
||||
);
|
||||
// Already wrapped in the surround. Remove it.
|
||||
this._insertAt(sel.start - hlen, sel.end + tlen, sel.value);
|
||||
this.selectText(sel.start - hlen, sel.value.length);
|
||||
} else {
|
||||
const contents = this._getMultilineContents(
|
||||
|
@ -224,7 +219,7 @@ export default Mixin.create({
|
|||
opts
|
||||
);
|
||||
|
||||
this.set("value", `${pre}${contents}${post}`);
|
||||
this._insertAt(sel.start, sel.end, contents);
|
||||
if (lines.length === 1 && tlen > 0) {
|
||||
this.selectText(sel.start + hlen, sel.value.length);
|
||||
} else {
|
||||
|
@ -280,27 +275,31 @@ export default Mixin.create({
|
|||
return;
|
||||
}
|
||||
|
||||
let pre = sel.pre;
|
||||
let post = sel.value + sel.post;
|
||||
let start = sel.start;
|
||||
let end = sel.end;
|
||||
|
||||
if (pre.length > 0) {
|
||||
pre = pre.replace(/\n*$/, "\n\n");
|
||||
const newLinesBeforeSelection = sel.pre?.match(/\n*$/)?.[0]?.length;
|
||||
if (newLinesBeforeSelection) {
|
||||
start -= newLinesBeforeSelection;
|
||||
}
|
||||
|
||||
if (post.length > 0) {
|
||||
post = post.replace(/^\n*/, "\n\n");
|
||||
if (sel.pre.length > 0) {
|
||||
text = `\n\n${text}`;
|
||||
}
|
||||
|
||||
const newLinesAfterSelection = sel.post?.match(/^\n*/)?.[0]?.length;
|
||||
if (newLinesAfterSelection) {
|
||||
end += newLinesAfterSelection;
|
||||
}
|
||||
|
||||
if (sel.post.length > 0) {
|
||||
text = `${text}\n\n`;
|
||||
} else {
|
||||
post = "\n";
|
||||
text = `${text}\n`;
|
||||
}
|
||||
|
||||
const value = pre + text + post;
|
||||
|
||||
this.set("value", value);
|
||||
|
||||
this._$textarea.val(value);
|
||||
this._$textarea.prop("selectionStart", (pre + text).length + 2);
|
||||
this._$textarea.prop("selectionEnd", (pre + text).length + 2);
|
||||
|
||||
this._insertAt(start, end, text);
|
||||
this._textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
schedule("afterRender", this, this.focusTextArea);
|
||||
},
|
||||
|
||||
|
@ -318,16 +317,16 @@ export default Mixin.create({
|
|||
}
|
||||
}
|
||||
|
||||
const insert = `${sel.pre}${text}`;
|
||||
const value = `${insert}${sel.post}`;
|
||||
this.set("value", value);
|
||||
this._$textarea.val(value);
|
||||
this._$textarea.prop("selectionStart", insert.length);
|
||||
this._$textarea.prop("selectionEnd", insert.length);
|
||||
next(() => this._$textarea.trigger("change"));
|
||||
this._insertAt(sel.start, sel.end, text);
|
||||
this.focusTextArea();
|
||||
},
|
||||
|
||||
_insertAt(start, end, text) {
|
||||
this._textarea.setSelectionRange(start, end);
|
||||
this._textarea.focus();
|
||||
document.execCommand("insertText", false, text);
|
||||
},
|
||||
|
||||
extractTable(text) {
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.substring(0, text.length - 1);
|
||||
|
|
|
@ -12,6 +12,7 @@ import { click, fillIn, settled, visit } from "@ember/test-helpers";
|
|||
import I18n from "I18n";
|
||||
import { skip, test } from "qunit";
|
||||
import { Promise } from "rsvp";
|
||||
import { isLegacyEmber } from "discourse-common/config/environment";
|
||||
|
||||
function pretender(server, helper) {
|
||||
server.post("/uploads/lookup-urls", () => {
|
||||
|
@ -73,10 +74,12 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
});
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const image = createFile("avatar.png");
|
||||
|
@ -149,11 +152,13 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
uploadStarted++;
|
||||
|
||||
if (uploadStarted === 2) {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n[Uploading: avatar2.png...]()\n",
|
||||
"it should show the upload placeholders when the upload starts"
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n[Uploading: avatar2.png...]()\n",
|
||||
"it should show the upload placeholders when the upload starts"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
appEvents.on("composer:uploads-cancelled", () => {
|
||||
|
@ -181,10 +186,13 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
const done = assert.async();
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
// Event handling is different in legacy - the text hasn't been inserted when this event fires
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on("composer:all-uploads-complete", () => {
|
||||
|
@ -211,10 +219,13 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
const done = assert.async();
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n Text after the image."
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
// Event handling is different in legacy - the text hasn't been inserted when this event fires
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n Text after the image."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on("composer:all-uploads-complete", () => {
|
||||
|
@ -244,10 +255,13 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
const done = assert.async();
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n Text after the image."
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
// Event handling is different in legacy - the text hasn't been inserted when this event fires
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n Text after the image."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on("composer:all-uploads-complete", () => {
|
||||
|
@ -269,10 +283,12 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
const done = assert.async();
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on("composer:all-uploads-complete", () => {
|
||||
|
@ -295,10 +311,12 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
|
|||
const done = assert.async();
|
||||
|
||||
appEvents.on("composer:upload-started", () => {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
if (!isLegacyEmber()) {
|
||||
assert.strictEqual(
|
||||
query(".d-editor-input").value,
|
||||
"The image:\n[Uploading: avatar.png...]()\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on("composer:all-uploads-complete", () => {
|
||||
|
|
|
@ -172,6 +172,22 @@ discourseModule("Integration | Component | d-editor", function (hooks) {
|
|||
assert.strictEqual(textarea.selectionEnd, 11);
|
||||
});
|
||||
|
||||
testCase(
|
||||
"bold button maintains undo history",
|
||||
async function (assert, textarea) {
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
|
||||
await click("button.bold");
|
||||
assert.strictEqual(this.value, "hello **world**.");
|
||||
assert.strictEqual(textarea.selectionStart, 8);
|
||||
assert.strictEqual(textarea.selectionEnd, 13);
|
||||
|
||||
document.execCommand("undo");
|
||||
assert.strictEqual(this.value, "hello world.");
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
`bold with a multiline selection`,
|
||||
async function (assert, textarea) {
|
||||
|
@ -472,6 +488,9 @@ third line`
|
|||
|
||||
assert.strictEqual(textarea.selectionStart, 27);
|
||||
assert.strictEqual(textarea.selectionEnd, 27);
|
||||
|
||||
document.execCommand("undo");
|
||||
assert.strictEqual(this.value, "first line\nsecond line\nthird line");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -509,6 +528,9 @@ third line`
|
|||
|
||||
await click("button.blockquote");
|
||||
assert.strictEqual(this.value, "one\n\n\n> \n> two");
|
||||
|
||||
document.execCommand("undo");
|
||||
assert.strictEqual(this.value, "one\n\n\n\ntwo");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -834,6 +856,9 @@ third line`
|
|||
let element = query(".d-editor");
|
||||
await paste(element, "\ta\tb\n1\t2\t3");
|
||||
assert.strictEqual(this.value, "||a|b|\n|---|---|---|\n|1|2|3|\n");
|
||||
|
||||
document.execCommand("undo");
|
||||
assert.strictEqual(this.value, "");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -863,6 +888,9 @@ third line`
|
|||
"See [discourse](https://www.discourse.org/) in action"
|
||||
);
|
||||
assert.strictEqual(event.defaultPrevented, true);
|
||||
|
||||
document.execCommand("undo");
|
||||
assert.strictEqual(this.value, "See discourse in action");
|
||||
}
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in New Issue