mirror of
https://github.com/discourse/discourse.git
synced 2025-05-25 00:32:52 +08:00

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.
249 lines
6.9 KiB
JavaScript
249 lines
6.9 KiB
JavaScript
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;
|