mirror of
https://github.com/discourse/discourse.git
synced 2025-05-25 00:32:52 +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),
|
...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) => {
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {};
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
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 {
|
#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -7,3 +7,5 @@ en:
|
|||||||
js:
|
js:
|
||||||
footnote:
|
footnote:
|
||||||
title: "Footnotes"
|
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
|
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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user