* fix: Logic error in toast fix: Remove useless component * fix: Logout not clearing all stores * Add icons to notification settings * Add eslint rule to enforce spaced comment * Add eslint rule for arrow-body-style * Add eslint rule to enforce self-closing components * Add menu to api key settings Fix: Deleting webhook subscription does not remove from UI Split webhook subscriptions into active and inactive Styling updates
278 lines
8.1 KiB
TypeScript
278 lines
8.1 KiB
TypeScript
import copy from "copy-to-clipboard";
|
|
import { textblockTypeInputRule } from "prosemirror-inputrules";
|
|
import {
|
|
Node as ProsemirrorNode,
|
|
NodeSpec,
|
|
NodeType,
|
|
Schema,
|
|
} from "prosemirror-model";
|
|
import { Plugin, Selection } from "prosemirror-state";
|
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
import Storage from "../../utils/Storage";
|
|
import backspaceToParagraph from "../commands/backspaceToParagraph";
|
|
import splitHeading from "../commands/splitHeading";
|
|
import toggleBlockType from "../commands/toggleBlockType";
|
|
import { Command } from "../lib/Extension";
|
|
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
|
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
|
import Node from "./Node";
|
|
|
|
export default class Heading extends Node {
|
|
className = "heading-name";
|
|
|
|
get name() {
|
|
return "heading";
|
|
}
|
|
|
|
get defaultOptions() {
|
|
return {
|
|
levels: [1, 2, 3, 4],
|
|
collapsed: undefined,
|
|
};
|
|
}
|
|
|
|
get schema(): NodeSpec {
|
|
return {
|
|
attrs: {
|
|
level: {
|
|
default: 1,
|
|
},
|
|
collapsed: {
|
|
default: undefined,
|
|
},
|
|
},
|
|
content: "inline*",
|
|
group: "block",
|
|
defining: true,
|
|
draggable: false,
|
|
parseDOM: this.options.levels.map((level: number) => ({
|
|
tag: `h${level}`,
|
|
attrs: { level },
|
|
contentElement: ".heading-content",
|
|
})),
|
|
toDOM: (node) => {
|
|
let anchor, fold;
|
|
if (typeof document !== "undefined") {
|
|
anchor = document.createElement("button");
|
|
anchor.innerText = "#";
|
|
anchor.type = "button";
|
|
anchor.className = "heading-anchor";
|
|
anchor.addEventListener("click", (event) =>
|
|
this.handleCopyLink(event)
|
|
);
|
|
|
|
fold = document.createElement("button");
|
|
fold.innerText = "";
|
|
fold.innerHTML =
|
|
'<svg fill="currentColor" width="12" height="24" viewBox="6 0 12 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.23823905,10.6097108 L11.207376,14.4695888 L11.207376,14.4695888 C11.54411,14.907343 12.1719566,14.989236 12.6097108,14.652502 C12.6783439,14.5997073 12.7398293,14.538222 12.792624,14.4695888 L15.761761,10.6097108 L15.761761,10.6097108 C16.0984949,10.1719566 16.0166019,9.54410997 15.5788477,9.20737601 C15.4040391,9.07290785 15.1896811,9 14.969137,9 L9.03086304,9 L9.03086304,9 C8.47857829,9 8.03086304,9.44771525 8.03086304,10 C8.03086304,10.2205442 8.10377089,10.4349022 8.23823905,10.6097108 Z" /></svg>';
|
|
fold.type = "button";
|
|
fold.className = `heading-fold ${
|
|
node.attrs.collapsed ? "collapsed" : ""
|
|
}`;
|
|
fold.addEventListener("mousedown", (event) =>
|
|
this.handleFoldContent(event)
|
|
);
|
|
}
|
|
|
|
return [
|
|
`h${node.attrs.level + (this.options.offset || 0)}`,
|
|
[
|
|
"span",
|
|
{
|
|
contentEditable: "false",
|
|
class: `heading-actions ${
|
|
node.attrs.collapsed ? "collapsed" : ""
|
|
}`,
|
|
},
|
|
...(anchor ? [anchor, fold] : []),
|
|
],
|
|
[
|
|
"span",
|
|
{
|
|
class: "heading-content",
|
|
},
|
|
0,
|
|
],
|
|
];
|
|
},
|
|
};
|
|
}
|
|
|
|
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
|
state.write(state.repeat("#", node.attrs.level) + " ");
|
|
state.renderInline(node);
|
|
state.closeBlock(node);
|
|
}
|
|
|
|
parseMarkdown() {
|
|
return {
|
|
block: "heading",
|
|
getAttrs: (token: Record<string, any>) => ({
|
|
level: +token.tag.slice(1),
|
|
}),
|
|
};
|
|
}
|
|
|
|
commands({ type, schema }: { type: NodeType; schema: Schema }) {
|
|
return (attrs: Record<string, any>) =>
|
|
toggleBlockType(type, schema.nodes.paragraph, attrs);
|
|
}
|
|
|
|
handleFoldContent = (event: MouseEvent) => {
|
|
event.preventDefault();
|
|
if (!(event.currentTarget instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const { view } = this.editor;
|
|
const hadFocus = view.hasFocus();
|
|
const { tr } = view.state;
|
|
const { top, left } = event.currentTarget.getBoundingClientRect();
|
|
const result = view.posAtCoords({ top, left });
|
|
|
|
if (result) {
|
|
const node = view.state.doc.nodeAt(result.inside);
|
|
|
|
if (node) {
|
|
const endOfHeadingPos = result.inside + node.nodeSize;
|
|
const $pos = view.state.doc.resolve(endOfHeadingPos);
|
|
const collapsed = !node.attrs.collapsed;
|
|
|
|
if (collapsed && view.state.selection.to > endOfHeadingPos) {
|
|
// move selection to the end of the collapsed heading
|
|
tr.setSelection(Selection.near($pos, -1));
|
|
}
|
|
|
|
const transaction = tr.setNodeMarkup(result.inside, undefined, {
|
|
...node.attrs,
|
|
collapsed,
|
|
});
|
|
|
|
const persistKey = headingToPersistenceKey(node, this.editor.props.id);
|
|
|
|
if (collapsed) {
|
|
Storage.set(persistKey, "collapsed");
|
|
} else {
|
|
Storage.remove(persistKey);
|
|
}
|
|
|
|
view.dispatch(transaction);
|
|
|
|
if (hadFocus) {
|
|
view.focus();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
handleCopyLink = (event: MouseEvent) => {
|
|
// this is unfortunate but appears to be the best way to grab the anchor
|
|
// as it's added directly to the dom by a decoration.
|
|
const anchor =
|
|
event.currentTarget instanceof HTMLButtonElement &&
|
|
(event.currentTarget.parentNode?.parentNode
|
|
?.previousSibling as HTMLElement);
|
|
|
|
if (!anchor || !anchor.className.includes(this.className)) {
|
|
throw new Error("Did not find anchor as previous sibling of heading");
|
|
}
|
|
const hash = `#${anchor.id}`;
|
|
|
|
// the existing url might contain a hash already, lets make sure to remove
|
|
// that rather than appending another one.
|
|
const urlWithoutHash = window.location.href.split("#")[0];
|
|
copy(urlWithoutHash + hash);
|
|
|
|
this.options.onShowToast(this.options.dictionary.linkCopied);
|
|
};
|
|
|
|
keys({ type, schema }: { type: NodeType; schema: Schema }) {
|
|
const options = this.options.levels.reduce(
|
|
(items: Record<string, Command>, level: number) => ({
|
|
...items,
|
|
...{
|
|
[`Shift-Ctrl-${level}`]: toggleBlockType(
|
|
type,
|
|
schema.nodes.paragraph,
|
|
{ level }
|
|
),
|
|
},
|
|
}),
|
|
{}
|
|
);
|
|
|
|
return {
|
|
...options,
|
|
Backspace: backspaceToParagraph(type),
|
|
Enter: splitHeading(type),
|
|
};
|
|
}
|
|
|
|
get plugins() {
|
|
const getAnchors = (doc: ProsemirrorNode) => {
|
|
const decorations: Decoration[] = [];
|
|
const previouslySeen = {};
|
|
|
|
doc.descendants((node, pos) => {
|
|
if (node.type.name !== this.name) {
|
|
return;
|
|
}
|
|
|
|
// calculate the optimal id
|
|
const slug = headingToSlug(node);
|
|
let id = slug;
|
|
|
|
// check if we've already used it, and if so how many times?
|
|
// Make the new id based on that number ensuring that we have
|
|
// unique ID's even when headings are identical
|
|
if (previouslySeen[slug] > 0) {
|
|
id = headingToSlug(node, previouslySeen[slug]);
|
|
}
|
|
|
|
// record that we've seen this slug for the next loop
|
|
previouslySeen[slug] =
|
|
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
|
|
|
|
decorations.push(
|
|
Decoration.widget(
|
|
pos,
|
|
() => {
|
|
const anchor = document.createElement("a");
|
|
anchor.id = id;
|
|
anchor.className = this.className;
|
|
return anchor;
|
|
},
|
|
{
|
|
side: -1,
|
|
key: id,
|
|
}
|
|
)
|
|
);
|
|
});
|
|
|
|
return DecorationSet.create(doc, decorations);
|
|
};
|
|
|
|
const plugin: Plugin = new Plugin({
|
|
state: {
|
|
init: (config, state) => getAnchors(state.doc),
|
|
apply: (tr, oldState) =>
|
|
tr.docChanged ? getAnchors(tr.doc) : oldState,
|
|
},
|
|
props: {
|
|
decorations: (state) => plugin.getState(state),
|
|
},
|
|
});
|
|
|
|
return [plugin];
|
|
}
|
|
|
|
inputRules({ type }: { type: NodeType }) {
|
|
return this.options.levels.map((level: number) =>
|
|
textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({
|
|
level,
|
|
}))
|
|
);
|
|
}
|
|
}
|