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:
Renato Atilio
2025-04-14 14:25:36 -03:00
committed by GitHub
parent a0a5b2889f
commit 29ca0ae0b1
14 changed files with 453 additions and 43 deletions

View File

@ -142,7 +142,11 @@ export default class ProsemirrorEditor extends Component {
...extractPlugins(this.extensions, params, this.handleAsyncPlugin), ...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.serializer = new Serializer(
this.extensions, this.extensions,
this.pluginParams, this.pluginParams,
@ -153,7 +157,7 @@ export default class ProsemirrorEditor extends Component {
this.view = new EditorView(container, { this.view = new EditorView(container, {
state, state,
nodeViews: extractNodeViews(this.extensions), nodeViews: extractNodeViews(this.extensions, this.pluginParams),
attributes: { class: this.args.class ?? "" }, attributes: { class: this.args.class ?? "" },
editable: () => this.args.disabled !== true, editable: () => this.args.disabled !== true,
dispatchTransaction: (tr) => { dispatchTransaction: (tr) => {

View File

@ -8,7 +8,8 @@ import { parse } from "../lib/markdown-it";
export default class Parser { export default class Parser {
#multipleParseSpecs = {}; #multipleParseSpecs = {};
constructor(extensions, includeDefault = true) { constructor(extensions, params, includeDefault = true) {
this.params = params;
this.parseTokens = includeDefault this.parseTokens = includeDefault
? { ? {
...defaultMarkdownParser.tokens, ...defaultMarkdownParser.tokens,
@ -44,10 +45,15 @@ export default class Parser {
#extractParsers(extensions) { #extractParsers(extensions) {
const parsers = {}; const parsers = {};
for (const { parse: parseObj } of extensions) { for (let { parse: parseObj } of extensions) {
if (!parseObj) { if (!parseObj) {
continue; continue;
} }
if (parseObj instanceof Function) {
parseObj = parseObj(this.params);
}
for (const [token, parseSpec] of Object.entries(parseObj)) { for (const [token, parseSpec] of Object.entries(parseObj)) {
if (parsers[token] !== undefined) { if (parsers[token] !== undefined) {
if (this.#multipleParseSpecs[token] === undefined) { if (this.#multipleParseSpecs[token] === undefined) {

View File

@ -1,11 +1,14 @@
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
export function extractNodeViews(extensions) { export function extractNodeViews(extensions, pluginParams) {
/** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */ /** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */
const allNodeViews = {}; const allNodeViews = {};
for (const { nodeViews } of extensions) { for (const { nodeViews } of extensions) {
if (nodeViews) { 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); allNodeViews[name] = (...args) => new NodeViewClass(...args);
} }
} }

View File

@ -1,10 +1,10 @@
import { import {
defaultMarkdownSerializer, defaultMarkdownSerializer,
MarkdownSerializer, MarkdownSerializerState,
} from "prosemirror-markdown"; } from "prosemirror-markdown";
export default class Serializer { export default class Serializer {
#pmSerializer; #afterSerializers;
constructor(extensions, pluginParams, includeDefault = true) { constructor(extensions, pluginParams, includeDefault = true) {
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {}; this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
@ -14,12 +14,28 @@ export default class Serializer {
this.#extractNodeSerializers(extensions, pluginParams); this.#extractNodeSerializers(extensions, pluginParams);
this.#extractMarkSerializers(extensions, pluginParams); this.#extractMarkSerializers(extensions, pluginParams);
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
} }
convert(doc) { 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) { #extractNodeSerializers(extensions, pluginParams) {
@ -28,7 +44,9 @@ export default class Serializer {
typeof serializeNode === "function" typeof serializeNode === "function"
? serializeNode(pluginParams) ? serializeNode(pluginParams)
: serializeNode; : serializeNode;
Object.assign(this.nodes, serializer); Object.assign(this.nodes, serializer);
this.#addAfterSerializer(serializer?.afterSerialize);
} }
} }

View File

@ -5,13 +5,14 @@
*/ */
const extension = { const extension = {
plugins({ plugins({
pmState: { Plugin }, pmState: { Plugin, PluginKey },
pmView: { Decoration, DecorationSet }, pmView: { Decoration, DecorationSet },
getContext, getContext,
}) { }) {
let placeholder; let placeholder;
return new Plugin({ return new Plugin({
key: new PluginKey("placeholder"),
view() { view() {
placeholder = getContext().placeholder; placeholder = getContext().placeholder;
return {}; return {};

View File

@ -198,7 +198,6 @@
// stylelint-disable-next-line no-duplicate-selectors // stylelint-disable-next-line no-duplicate-selectors
.ProseMirror { .ProseMirror {
position: relative;
word-wrap: break-word; word-wrap: break-word;
white-space: break-spaces; white-space: break-spaces;
} }

View File

@ -23,10 +23,9 @@ describe "Composer - ProseMirror editor - Local Dates extension", type: :system
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
rich.click rich.click
cdp.write_clipboard <<~MARKDOWN cdp.copy_paste <<~MARKDOWN
[date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"] [date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"]
MARKDOWN MARKDOWN
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css( expect(rich).to have_css(
"span.discourse-local-date[data-timezone='Asia/Singapore']", "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 open_composer_and_toggle_rich_editor
rich.click 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"] [date-range from=2022-12-15T14:19:00 to=2022-12-16T15:20:00 timezone="Asia/Singapore"]
MARKDOWN MARKDOWN
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css("span.discourse-local-date-range") expect(rich).to have_css("span.discourse-local-date-range")
expect(rich).to have_css( expect(rich).to have_css(

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

View 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;

View File

@ -111,3 +111,48 @@
#footnote-tooltip[data-popper-placement^="right"] > #arrow { #footnote-tooltip[data-popper-placement^="right"] > #arrow {
left: -4px; 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);
}
}
}

View File

@ -7,3 +7,5 @@ en:
js: js:
footnote: footnote:
title: "Footnotes" title: "Footnotes"
add: "Add footnote"

View 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

View File

@ -230,8 +230,7 @@ describe "Composer - ProseMirror editor", type: :system do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
composer.type_content("Check out this link ") composer.type_content("Check out this link ")
cdp.write_clipboard("https://example.com/x") cdp.copy_paste("https://example.com/x")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
composer.type_content(" ").type_content("in the middle of text") composer.type_content(" ").type_content("in the middle of text")
expect(rich).to have_css( 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 it "creates a full onebox for standalone links" do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
cdp.write_clipboard("https://example.com") cdp.copy_paste("https://example.com")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
page.send_keys(:enter) page.send_keys(:enter)
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example.com']") 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 cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
composer.type_content("Some text ") composer.type_content("Some text ")
cdp.write_clipboard("https://example.com/x") cdp.copy_paste("https://example.com/x")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
composer.type_content(" ").type_content("more text") composer.type_content(" ").type_content("more text")
expect(rich).to have_no_css("div.onebox-wrapper") expect(rich).to have_no_css("div.onebox-wrapper")
@ -282,8 +279,7 @@ describe "Composer - ProseMirror editor", type: :system do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
composer.type_content("```") composer.type_content("```")
cdp.write_clipboard("https://example.com") cdp.copy_paste("https://example.com")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css("pre code") expect(rich).to have_css("pre code")
expect(rich).to have_no_css("div.onebox-wrapper") 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 Ok, that is it https://example3.com/x
After a hard break After a hard break
MARKDOWN MARKDOWN
cdp.write_clipboard(markdown) cdp.copy_paste(markdown)
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css("a.inline-onebox", count: 6) expect(rich).to have_css("a.inline-onebox", count: 6)
expect(rich).to have_css( expect(rich).to have_css(
@ -355,10 +350,9 @@ describe "Composer - ProseMirror editor", type: :system do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
composer.type_content("Hey ") composer.type_content("Hey ")
cdp.write_clipboard("https://example.com/x") cdp.copy_paste("https://example.com/x")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
composer.type_content(" ").type_content("and").type_content(" ") composer.type_content(" ").type_content("and").type_content(" ")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"]) cdp.paste
composer.type_content("\n") composer.type_content("\n")
expect(rich).to have_css( expect(rich).to have_css(
@ -517,13 +511,12 @@ describe "Composer - ProseMirror editor", type: :system do
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
# The example is a bit convoluted, but it's the simplest way to reproduce the issue. # 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") puts SiteSetting.all_settings(filter_categories: ["uncategorized"]).map { |setting| setting[:setting] }.join("\n")
``` ```
MARKDOWN 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( expect(page.driver.browser.logs.get(:browser)).not_to include(
"Maximum call stack size exceeded", "Maximum call stack size exceeded",
) )
@ -535,11 +528,10 @@ describe "Composer - ProseMirror editor", type: :system do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
cdp.write_clipboard( cdp.copy_paste(
'<img src="image.png" alt="alt text" data-base62-sha1="1234567890">', '<img src="image.png" alt="alt text" data-base62-sha1="1234567890">',
html: true, html: true,
) )
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
expect(page).to have_css( expect(page).to have_css(
"img[src$='image.png'][alt='alt text'][data-orig-src='upload://1234567890']", "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 it "respects existing marks when pasting a url to make a link" do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
cdp.write_clipboard("not selected `code`**bold**not*italic* not selected") cdp.copy_paste("not selected `code`**bold**not*italic* not selected")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
rich.find("strong").double_click rich.find("strong").double_click
cdp.write_clipboard("www.example.com") cdp.copy_paste("www.example.com")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css("code", text: "code") expect(rich).to have_css("code", text: "code")
expect(rich).to have_css("strong", text: "bold") expect(rich).to have_css("strong", text: "bold")
@ -571,12 +561,10 @@ describe "Composer - ProseMirror editor", type: :system do
cdp.allow_clipboard cdp.allow_clipboard
open_composer_and_toggle_rich_editor open_composer_and_toggle_rich_editor
cdp.write_clipboard("not selected **bold** not selected") cdp.copy_paste("not selected **bold** not selected")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
rich.find("strong").double_click rich.find("strong").double_click
cdp.write_clipboard("<p>www.example.com</p>", html: true) cdp.copy_paste("<p>www.example.com</p>", html: true)
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
composer.toggle_rich_editor composer.toggle_rich_editor

View File

@ -59,6 +59,10 @@ module PageObjects
def copy_paste(text, html: false) def copy_paste(text, html: false)
allow_clipboard allow_clipboard
write_clipboard(text, html: html) write_clipboard(text, html: html)
paste
end
def paste
page.send_keys([PLATFORM_KEY_MODIFIER, "v"]) page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
end end