mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 23:41:04 +08:00
FEATURE: add footnote (plugin) rich editor extension (#31719)
Continues the work done on https://github.com/discourse/discourse/pull/30815. Adds a `footnote` node, parser, `^[inline]` input rule, toolbar button item, and serializer. Also adds a NodeView with an internal ProseMirror editor to edit the footnote content.
This commit is contained in:
@ -142,7 +142,11 @@ export default class ProsemirrorEditor extends Component {
|
||||
...extractPlugins(this.extensions, params, this.handleAsyncPlugin),
|
||||
];
|
||||
|
||||
this.parser = new Parser(this.extensions, this.args.includeDefault);
|
||||
this.parser = new Parser(
|
||||
this.extensions,
|
||||
this.pluginParams,
|
||||
this.args.includeDefault
|
||||
);
|
||||
this.serializer = new Serializer(
|
||||
this.extensions,
|
||||
this.pluginParams,
|
||||
@ -153,7 +157,7 @@ export default class ProsemirrorEditor extends Component {
|
||||
|
||||
this.view = new EditorView(container, {
|
||||
state,
|
||||
nodeViews: extractNodeViews(this.extensions),
|
||||
nodeViews: extractNodeViews(this.extensions, this.pluginParams),
|
||||
attributes: { class: this.args.class ?? "" },
|
||||
editable: () => this.args.disabled !== true,
|
||||
dispatchTransaction: (tr) => {
|
||||
|
@ -8,7 +8,8 @@ import { parse } from "../lib/markdown-it";
|
||||
export default class Parser {
|
||||
#multipleParseSpecs = {};
|
||||
|
||||
constructor(extensions, includeDefault = true) {
|
||||
constructor(extensions, params, includeDefault = true) {
|
||||
this.params = params;
|
||||
this.parseTokens = includeDefault
|
||||
? {
|
||||
...defaultMarkdownParser.tokens,
|
||||
@ -44,10 +45,15 @@ export default class Parser {
|
||||
|
||||
#extractParsers(extensions) {
|
||||
const parsers = {};
|
||||
for (const { parse: parseObj } of extensions) {
|
||||
for (let { parse: parseObj } of extensions) {
|
||||
if (!parseObj) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parseObj instanceof Function) {
|
||||
parseObj = parseObj(this.params);
|
||||
}
|
||||
|
||||
for (const [token, parseSpec] of Object.entries(parseObj)) {
|
||||
if (parsers[token] !== undefined) {
|
||||
if (this.#multipleParseSpecs[token] === undefined) {
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Plugin } from "prosemirror-state";
|
||||
|
||||
export function extractNodeViews(extensions) {
|
||||
export function extractNodeViews(extensions, pluginParams) {
|
||||
/** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */
|
||||
const allNodeViews = {};
|
||||
for (const { nodeViews } of extensions) {
|
||||
if (nodeViews) {
|
||||
for (const [name, NodeViewClass] of Object.entries(nodeViews)) {
|
||||
for (let [name, NodeViewClass] of Object.entries(nodeViews)) {
|
||||
if (!NodeViewClass.toString().startsWith("class")) {
|
||||
NodeViewClass = NodeViewClass(pluginParams);
|
||||
}
|
||||
allNodeViews[name] = (...args) => new NodeViewClass(...args);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {
|
||||
defaultMarkdownSerializer,
|
||||
MarkdownSerializer,
|
||||
MarkdownSerializerState,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
export default class Serializer {
|
||||
#pmSerializer;
|
||||
#afterSerializers;
|
||||
|
||||
constructor(extensions, pluginParams, includeDefault = true) {
|
||||
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
|
||||
@ -14,12 +14,28 @@ export default class Serializer {
|
||||
|
||||
this.#extractNodeSerializers(extensions, pluginParams);
|
||||
this.#extractMarkSerializers(extensions, pluginParams);
|
||||
|
||||
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
|
||||
}
|
||||
|
||||
convert(doc) {
|
||||
return this.#pmSerializer.serialize(doc);
|
||||
const state = new MarkdownSerializerState(this.nodes, this.marks, {});
|
||||
state.renderContent(doc.content);
|
||||
|
||||
if (this.#afterSerializers) {
|
||||
for (const afterSerializer of this.#afterSerializers) {
|
||||
afterSerializer(state);
|
||||
}
|
||||
}
|
||||
|
||||
return state.out;
|
||||
}
|
||||
|
||||
#addAfterSerializer(callback) {
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#afterSerializers ??= [];
|
||||
this.#afterSerializers.push(callback);
|
||||
}
|
||||
|
||||
#extractNodeSerializers(extensions, pluginParams) {
|
||||
@ -28,7 +44,9 @@ export default class Serializer {
|
||||
typeof serializeNode === "function"
|
||||
? serializeNode(pluginParams)
|
||||
: serializeNode;
|
||||
|
||||
Object.assign(this.nodes, serializer);
|
||||
this.#addAfterSerializer(serializer?.afterSerialize);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,13 +5,14 @@
|
||||
*/
|
||||
const extension = {
|
||||
plugins({
|
||||
pmState: { Plugin },
|
||||
pmState: { Plugin, PluginKey },
|
||||
pmView: { Decoration, DecorationSet },
|
||||
getContext,
|
||||
}) {
|
||||
let placeholder;
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("placeholder"),
|
||||
view() {
|
||||
placeholder = getContext().placeholder;
|
||||
return {};
|
||||
|
@ -198,7 +198,6 @@
|
||||
|
||||
// stylelint-disable-next-line no-duplicate-selectors
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
@ -23,10 +23,9 @@ describe "Composer - ProseMirror editor - Local Dates extension", type: :system
|
||||
open_composer_and_toggle_rich_editor
|
||||
rich.click
|
||||
|
||||
cdp.write_clipboard <<~MARKDOWN
|
||||
cdp.copy_paste <<~MARKDOWN
|
||||
[date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"]
|
||||
MARKDOWN
|
||||
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
|
||||
|
||||
expect(rich).to have_css(
|
||||
"span.discourse-local-date[data-timezone='Asia/Singapore']",
|
||||
@ -39,10 +38,9 @@ describe "Composer - ProseMirror editor - Local Dates extension", type: :system
|
||||
open_composer_and_toggle_rich_editor
|
||||
rich.click
|
||||
|
||||
cdp.write_clipboard <<~MARKDOWN
|
||||
cdp.copy_paste <<~MARKDOWN
|
||||
[date-range from=2022-12-15T14:19:00 to=2022-12-16T15:20:00 timezone="Asia/Singapore"]
|
||||
MARKDOWN
|
||||
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
|
||||
|
||||
expect(rich).to have_css("span.discourse-local-date-range")
|
||||
expect(rich).to have_css(
|
||||
|
22
plugins/footnote/assets/javascripts/initializers/composer.js
Normal file
22
plugins/footnote/assets/javascripts/initializers/composer.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import richEditorExtension from "../lib/rich-editor-extension";
|
||||
|
||||
export default {
|
||||
name: "footnotes-composer",
|
||||
|
||||
initialize() {
|
||||
withPluginApi((api) => {
|
||||
api.registerRichEditorExtension(richEditorExtension);
|
||||
|
||||
api.addComposerToolbarPopupMenuOption({
|
||||
action(event) {
|
||||
event.addText(`^[${i18n("footnote.title")}]`);
|
||||
},
|
||||
group: "insertions",
|
||||
icon: "asterisk",
|
||||
label: "footnote.add",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
248
plugins/footnote/assets/javascripts/lib/rich-editor-extension.js
Normal file
248
plugins/footnote/assets/javascripts/lib/rich-editor-extension.js
Normal file
@ -0,0 +1,248 @@
|
||||
function createFootnoteNodeView({
|
||||
pmView: { EditorView },
|
||||
pmState: { EditorState },
|
||||
pmTransform: { StepMap },
|
||||
}) {
|
||||
// from https://prosemirror.net/examples/footnote/
|
||||
return class FootnoteNodeView {
|
||||
constructor(node, view, getPos) {
|
||||
this.node = node;
|
||||
this.outerView = view;
|
||||
this.getPos = getPos;
|
||||
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "footnote";
|
||||
this.innerView = null;
|
||||
}
|
||||
|
||||
selectNode() {
|
||||
this.dom.classList.add("ProseMirror-selectednode");
|
||||
if (!this.innerView) {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
deselectNode() {
|
||||
this.dom.classList.remove("ProseMirror-selectednode");
|
||||
if (this.innerView) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
const tooltip = this.dom.appendChild(document.createElement("div"));
|
||||
tooltip.style.setProperty(
|
||||
"--footnote-counter",
|
||||
`"${this.#getFootnoteCounterValue()}"`
|
||||
);
|
||||
tooltip.className = "footnote-tooltip";
|
||||
|
||||
this.innerView = new EditorView(tooltip, {
|
||||
state: EditorState.create({
|
||||
doc: this.node,
|
||||
plugins: this.outerView.state.plugins.filter(
|
||||
(plugin) =>
|
||||
!/^(placeholder|trailing-paragraph)\$.*/.test(plugin.key)
|
||||
),
|
||||
}),
|
||||
dispatchTransaction: this.dispatchInner.bind(this),
|
||||
handleDOMEvents: {
|
||||
mousedown: () => {
|
||||
// Kludge to prevent issues due to the fact that the whole
|
||||
// footnote is node-selected (and thus DOM-selected) when
|
||||
// the parent editor is focused.
|
||||
if (this.outerView.hasFocus()) {
|
||||
this.innerView.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#getFootnoteCounterValue() {
|
||||
const footnotes = this.dom
|
||||
.closest(".ProseMirror")
|
||||
?.querySelectorAll(".footnote");
|
||||
|
||||
return Array.from(footnotes).indexOf(this.dom) + 1;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.innerView.destroy();
|
||||
this.innerView = null;
|
||||
this.dom.textContent = "";
|
||||
}
|
||||
|
||||
dispatchInner(tr) {
|
||||
const { state, transactions } = this.innerView.state.applyTransaction(tr);
|
||||
this.innerView.updateState(state);
|
||||
|
||||
if (!tr.getMeta("fromOutside")) {
|
||||
const outerTr = this.outerView.state.tr,
|
||||
offsetMap = StepMap.offset(this.getPos() + 1);
|
||||
for (let i = 0; i < transactions.length; i++) {
|
||||
const steps = transactions[i].steps;
|
||||
for (let j = 0; j < steps.length; j++) {
|
||||
outerTr.step(steps[j].map(offsetMap));
|
||||
}
|
||||
}
|
||||
if (outerTr.docChanged) {
|
||||
this.outerView.dispatch(outerTr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(node) {
|
||||
if (!node.sameMarkup(this.node)) {
|
||||
return false;
|
||||
}
|
||||
this.node = node;
|
||||
if (this.innerView) {
|
||||
const state = this.innerView.state;
|
||||
const start = node.content.findDiffStart(state.doc.content);
|
||||
if (start != null) {
|
||||
let { a: endA, b: endB } = node.content.findDiffEnd(
|
||||
state.doc.content
|
||||
);
|
||||
let overlap = start - Math.min(endA, endB);
|
||||
if (overlap > 0) {
|
||||
endA += overlap;
|
||||
endB += overlap;
|
||||
}
|
||||
this.innerView.dispatch(
|
||||
state.tr
|
||||
.replace(start, endB, node.slice(start, endA))
|
||||
.setMeta("fromOutside", true)
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.innerView) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
stopEvent(event) {
|
||||
return this.innerView && this.innerView.dom.contains(event.target);
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {RichEditorExtension} */
|
||||
const extension = {
|
||||
nodeViews: { footnote: createFootnoteNodeView },
|
||||
nodeSpec: {
|
||||
footnote: {
|
||||
attrs: { id: {} },
|
||||
group: "inline",
|
||||
content: "block*",
|
||||
inline: true,
|
||||
atom: true,
|
||||
draggable: false,
|
||||
parseDOM: [{ tag: "div.footnote" }],
|
||||
toDOM: () => ["div", { class: "footnote" }, 0],
|
||||
},
|
||||
},
|
||||
parse({ pmModel: { Slice, Fragment } }) {
|
||||
return {
|
||||
footnote_ref: {
|
||||
node: "footnote",
|
||||
getAttrs: (token) => {
|
||||
return { id: token.meta.id };
|
||||
},
|
||||
},
|
||||
footnote_block: { ignore: true },
|
||||
footnote_open(state, token, tokens, i) {
|
||||
// footnote_open should be at the root level
|
||||
const doc = state.top();
|
||||
|
||||
const id = token.meta.id;
|
||||
let innerTokens = tokens.slice(i + 1, tokens.length - 1);
|
||||
const footnoteCloseIndex = innerTokens.findIndex(
|
||||
(t) => t.type === "footnote_close"
|
||||
);
|
||||
innerTokens = innerTokens.slice(0, footnoteCloseIndex);
|
||||
|
||||
doc.content.forEach((node, pos) => {
|
||||
const replacements = [];
|
||||
node.descendants((child, childPos) => {
|
||||
if (child.type.name !== "footnote" || child.attrs.id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is a trick to parse this subset of tokens having the footnote as parent
|
||||
state.stack = [];
|
||||
state.openNode(state.schema.nodes.footnote);
|
||||
state.parseTokens(innerTokens);
|
||||
const footnote = state.closeNode();
|
||||
state.stack = [doc];
|
||||
// then we restore the stack as it was before
|
||||
|
||||
const slice = new Slice(Fragment.from(footnote), 0, 0);
|
||||
replacements.push({ from: childPos, to: childPos + 2, slice });
|
||||
});
|
||||
|
||||
for (const { from, to, slice } of replacements) {
|
||||
doc.content[pos] = doc.content[pos].replace(from, to, slice);
|
||||
}
|
||||
});
|
||||
|
||||
// remove the inner tokens + footnote_close from the tokens stream
|
||||
tokens.splice(i + 1, innerTokens.length + 1);
|
||||
},
|
||||
footnote_anchor: { ignore: true, noCloseToken: true },
|
||||
};
|
||||
},
|
||||
serializeNode: {
|
||||
footnote(state, node) {
|
||||
if (
|
||||
node.content.content.length === 1 &&
|
||||
node.content.firstChild.type.name === "paragraph"
|
||||
) {
|
||||
state.write(`^[`);
|
||||
state.renderContent(node.content.firstChild);
|
||||
state.write(`]`);
|
||||
} else {
|
||||
const contents = (state.footnoteContents ??= []);
|
||||
contents.push(node.content);
|
||||
state.write(`[^${contents.length}]`);
|
||||
}
|
||||
},
|
||||
afterSerialize(state) {
|
||||
const contents = state.footnoteContents;
|
||||
|
||||
if (!contents) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
const oldDelim = state.delim;
|
||||
state.write(`[^${i + 1}]: `);
|
||||
state.delim += " ";
|
||||
state.renderContent(contents[i]);
|
||||
state.delim = oldDelim;
|
||||
}
|
||||
},
|
||||
},
|
||||
inputRules: [
|
||||
{
|
||||
match: /\^\[(.*?)]$/,
|
||||
handler: (state, match, start, end) => {
|
||||
const footnote = state.schema.nodes.footnote.create(
|
||||
null,
|
||||
state.schema.nodes.paragraph.create(null, state.schema.text(match[1]))
|
||||
);
|
||||
return state.tr.replaceWith(start, end, footnote);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default extension;
|
@ -111,3 +111,48 @@
|
||||
#footnote-tooltip[data-popper-placement^="right"] > #arrow {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
counter-reset: prosemirror-footnote;
|
||||
|
||||
.footnote {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
padding: 0 0.125em;
|
||||
display: inline-block;
|
||||
content: "[" counter(prosemirror-footnote) "]";
|
||||
vertical-align: super;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
counter-increment: prosemirror-footnote;
|
||||
}
|
||||
}
|
||||
|
||||
.footnote-tooltip {
|
||||
cursor: auto;
|
||||
position: absolute;
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
background-color: var(--primary-50);
|
||||
border-radius: var(--d-border-radius);
|
||||
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--primary-low);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0.1rem;
|
||||
left: 0.25rem;
|
||||
position: absolute;
|
||||
content: "[" var(--footnote-counter) "]:";
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary-low-mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,3 +7,5 @@ en:
|
||||
js:
|
||||
footnote:
|
||||
title: "Footnotes"
|
||||
add: "Add footnote"
|
||||
|
||||
|
72
plugins/footnote/spec/system/rich_editor_extension_spec.rb
Normal file
72
plugins/footnote/spec/system/rich_editor_extension_spec.rb
Normal file
@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe "Composer - ProseMirror editor - Footnote extension", type: :system do
|
||||
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
||||
let(:cdp) { PageObjects::CDP.new }
|
||||
let(:composer) { PageObjects::Components::Composer.new }
|
||||
let(:rich) { composer.rich_editor }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
SiteSetting.rich_editor = true
|
||||
end
|
||||
|
||||
def open_composer_and_toggle_rich_editor
|
||||
page.visit "/new-topic"
|
||||
expect(composer).to be_opened
|
||||
composer.toggle_rich_editor
|
||||
end
|
||||
|
||||
describe "pasting content" do
|
||||
it "converts inline footnotes" do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
rich.click
|
||||
|
||||
cdp.copy_paste <<~MARKDOWN
|
||||
What is this? ^[multiple inline] ^[footnotes]
|
||||
MARKDOWN
|
||||
|
||||
expect(rich).to have_css("div.footnote", count: 2)
|
||||
|
||||
composer.toggle_rich_editor
|
||||
|
||||
expect(composer).to have_value("What is this? ^[multiple inline] ^[footnotes]")
|
||||
end
|
||||
|
||||
it "converts block footnotes" do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
rich.click
|
||||
|
||||
cdp.copy_paste <<~MARKDOWN
|
||||
Hey [^1] [^2]
|
||||
[^1]: This is inline
|
||||
[^2]: This
|
||||
|
||||
> not so much
|
||||
MARKDOWN
|
||||
|
||||
expect(rich).to have_css("div.footnote", count: 2)
|
||||
|
||||
composer.toggle_rich_editor
|
||||
|
||||
expect(composer).to have_value(
|
||||
"Hey ^[This is inline] [^1]\n\n[^1]: This\n\n > not so much",
|
||||
)
|
||||
end
|
||||
|
||||
it "converts inline footnotes when typing" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
rich.click
|
||||
|
||||
rich.send_keys("What is this? ^[multiple inline] ^[footnotes]")
|
||||
|
||||
expect(rich).to have_css("div.footnote", count: 2)
|
||||
|
||||
composer.toggle_rich_editor
|
||||
|
||||
expect(composer).to have_value("What is this? ^[multiple inline] ^[footnotes]")
|
||||
end
|
||||
end
|
||||
end
|
@ -230,8 +230,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
composer.type_content("Check out this link ")
|
||||
cdp.write_clipboard("https://example.com/x")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("https://example.com/x")
|
||||
composer.type_content(" ").type_content("in the middle of text")
|
||||
|
||||
expect(rich).to have_css(
|
||||
@ -249,8 +248,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
it "creates a full onebox for standalone links" do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
cdp.write_clipboard("https://example.com")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("https://example.com")
|
||||
page.send_keys(:enter)
|
||||
|
||||
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example.com']")
|
||||
@ -266,8 +264,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
composer.type_content("Some text ")
|
||||
cdp.write_clipboard("https://example.com/x")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("https://example.com/x")
|
||||
composer.type_content(" ").type_content("more text")
|
||||
|
||||
expect(rich).to have_no_css("div.onebox-wrapper")
|
||||
@ -282,8 +279,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
composer.type_content("```")
|
||||
cdp.write_clipboard("https://example.com")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("https://example.com")
|
||||
|
||||
expect(rich).to have_css("pre code")
|
||||
expect(rich).to have_no_css("div.onebox-wrapper")
|
||||
@ -324,8 +320,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
Ok, that is it https://example3.com/x
|
||||
After a hard break
|
||||
MARKDOWN
|
||||
cdp.write_clipboard(markdown)
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste(markdown)
|
||||
|
||||
expect(rich).to have_css("a.inline-onebox", count: 6)
|
||||
expect(rich).to have_css(
|
||||
@ -355,10 +350,9 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
composer.type_content("Hey ")
|
||||
cdp.write_clipboard("https://example.com/x")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("https://example.com/x")
|
||||
composer.type_content(" ").type_content("and").type_content(" ")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.paste
|
||||
composer.type_content("\n")
|
||||
|
||||
expect(rich).to have_css(
|
||||
@ -517,13 +511,12 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
# The example is a bit convoluted, but it's the simplest way to reproduce the issue.
|
||||
cdp.write_clipboard <<~MARKDOWN
|
||||
composer.type_content("This is a test\n\n")
|
||||
cdp.copy_paste <<~MARKDOWN
|
||||
```
|
||||
puts SiteSetting.all_settings(filter_categories: ["uncategorized"]).map { |setting| setting[:setting] }.join("\n")
|
||||
```
|
||||
MARKDOWN
|
||||
composer.type_content("This is a test\n\n")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
expect(page.driver.browser.logs.get(:browser)).not_to include(
|
||||
"Maximum call stack size exceeded",
|
||||
)
|
||||
@ -535,11 +528,10 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
cdp.write_clipboard(
|
||||
cdp.copy_paste(
|
||||
'<img src="image.png" alt="alt text" data-base62-sha1="1234567890">',
|
||||
html: true,
|
||||
)
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
|
||||
expect(page).to have_css(
|
||||
"img[src$='image.png'][alt='alt text'][data-orig-src='upload://1234567890']",
|
||||
@ -549,12 +541,10 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
it "respects existing marks when pasting a url to make a link" do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
cdp.write_clipboard("not selected `code`**bold**not*italic* not selected")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("not selected `code`**bold**not*italic* not selected")
|
||||
rich.find("strong").double_click
|
||||
|
||||
cdp.write_clipboard("www.example.com")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("www.example.com")
|
||||
|
||||
expect(rich).to have_css("code", text: "code")
|
||||
expect(rich).to have_css("strong", text: "bold")
|
||||
@ -571,12 +561,10 @@ describe "Composer - ProseMirror editor", type: :system do
|
||||
cdp.allow_clipboard
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
cdp.write_clipboard("not selected **bold** not selected")
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("not selected **bold** not selected")
|
||||
rich.find("strong").double_click
|
||||
|
||||
cdp.write_clipboard("<p>www.example.com</p>", html: true)
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
cdp.copy_paste("<p>www.example.com</p>", html: true)
|
||||
|
||||
composer.toggle_rich_editor
|
||||
|
||||
|
@ -59,6 +59,10 @@ module PageObjects
|
||||
def copy_paste(text, html: false)
|
||||
allow_clipboard
|
||||
write_clipboard(text, html: html)
|
||||
paste
|
||||
end
|
||||
|
||||
def paste
|
||||
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
|
||||
end
|
||||
|
||||
|
Reference in New Issue
Block a user