From 29ca0ae0b101f07ec3dfcbae943abdc76e0cff30 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Mon, 14 Apr 2025 14:25:36 -0300 Subject: [PATCH] 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. --- .../components/prosemirror-editor.gjs | 8 +- .../app/static/prosemirror/core/parser.js | 10 +- .../app/static/prosemirror/core/plugin.js | 7 +- .../app/static/prosemirror/core/serializer.js | 28 +- .../prosemirror/extensions/placeholder.js | 3 +- .../common/rich-editor/rich-editor.scss | 1 - .../spec/system/rich_editor_extension_spec.rb | 6 +- .../javascripts/initializers/composer.js | 22 ++ .../javascripts/lib/rich-editor-extension.js | 248 ++++++++++++++++++ .../assets/stylesheets/footnotes.scss | 45 ++++ plugins/footnote/config/locales/client.en.yml | 2 + .../spec/system/rich_editor_extension_spec.rb | 72 +++++ .../composer/prosemirror_editor_spec.rb | 40 +-- spec/system/page_objects/cdp.rb | 4 + 14 files changed, 453 insertions(+), 43 deletions(-) create mode 100644 plugins/footnote/assets/javascripts/initializers/composer.js create mode 100644 plugins/footnote/assets/javascripts/lib/rich-editor-extension.js create mode 100644 plugins/footnote/spec/system/rich_editor_extension_spec.rb 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 b768818e66d..247c9b43ef0 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 @@ -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) => { diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/core/parser.js b/app/assets/javascripts/discourse/app/static/prosemirror/core/parser.js index 11549a35ddc..f3e453d3a99 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/core/parser.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/core/parser.js @@ -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) { diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/core/plugin.js b/app/assets/javascripts/discourse/app/static/prosemirror/core/plugin.js index ede21472af7..753d47c6574 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/core/plugin.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/core/plugin.js @@ -1,11 +1,14 @@ import { Plugin } from "prosemirror-state"; -export function extractNodeViews(extensions) { +export function extractNodeViews(extensions, pluginParams) { /** @type {Record} */ 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); } } diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js b/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js index 4acd4b007cc..2e604572a9c 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js @@ -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); } } diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/placeholder.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/placeholder.js index ac0cdee99a6..542d6d6fd74 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/placeholder.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/placeholder.js @@ -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 {}; diff --git a/app/assets/stylesheets/common/rich-editor/rich-editor.scss b/app/assets/stylesheets/common/rich-editor/rich-editor.scss index a0016c59860..191a8cd8abf 100644 --- a/app/assets/stylesheets/common/rich-editor/rich-editor.scss +++ b/app/assets/stylesheets/common/rich-editor/rich-editor.scss @@ -198,7 +198,6 @@ // stylelint-disable-next-line no-duplicate-selectors .ProseMirror { - position: relative; word-wrap: break-word; white-space: break-spaces; } diff --git a/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb b/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb index a13a6152836..c5667b70836 100644 --- a/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb +++ b/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb @@ -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( diff --git a/plugins/footnote/assets/javascripts/initializers/composer.js b/plugins/footnote/assets/javascripts/initializers/composer.js new file mode 100644 index 00000000000..704db7eb695 --- /dev/null +++ b/plugins/footnote/assets/javascripts/initializers/composer.js @@ -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", + }); + }); + }, +}; diff --git a/plugins/footnote/assets/javascripts/lib/rich-editor-extension.js b/plugins/footnote/assets/javascripts/lib/rich-editor-extension.js new file mode 100644 index 00000000000..422eddb8a32 --- /dev/null +++ b/plugins/footnote/assets/javascripts/lib/rich-editor-extension.js @@ -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; diff --git a/plugins/footnote/assets/stylesheets/footnotes.scss b/plugins/footnote/assets/stylesheets/footnotes.scss index 9c2a597b64b..dce67b05723 100644 --- a/plugins/footnote/assets/stylesheets/footnotes.scss +++ b/plugins/footnote/assets/stylesheets/footnotes.scss @@ -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); + } + } +} diff --git a/plugins/footnote/config/locales/client.en.yml b/plugins/footnote/config/locales/client.en.yml index 81f49ce905e..9f8916b6808 100644 --- a/plugins/footnote/config/locales/client.en.yml +++ b/plugins/footnote/config/locales/client.en.yml @@ -7,3 +7,5 @@ en: js: footnote: title: "Footnotes" + add: "Add footnote" + diff --git a/plugins/footnote/spec/system/rich_editor_extension_spec.rb b/plugins/footnote/spec/system/rich_editor_extension_spec.rb new file mode 100644 index 00000000000..228ed0397d2 --- /dev/null +++ b/plugins/footnote/spec/system/rich_editor_extension_spec.rb @@ -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 diff --git a/spec/system/composer/prosemirror_editor_spec.rb b/spec/system/composer/prosemirror_editor_spec.rb index 227db956325..9dfef6582b2 100644 --- a/spec/system/composer/prosemirror_editor_spec.rb +++ b/spec/system/composer/prosemirror_editor_spec.rb @@ -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( 'alt text', 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("

www.example.com

", html: true) - page.send_keys([PLATFORM_KEY_MODIFIER, "v"]) + cdp.copy_paste("

www.example.com

", html: true) composer.toggle_rich_editor diff --git a/spec/system/page_objects/cdp.rb b/spec/system/page_objects/cdp.rb index 8005f2b35ba..b840a64a26b 100644 --- a/spec/system/page_objects/cdp.rb +++ b/spec/system/page_objects/cdp.rb @@ -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