Compare commits

...

5 Commits

Author SHA1 Message Date
Apoorv Mishra 5a9444fda2 fix: gardening 2025-02-12 15:28:57 +05:30
Apoorv Mishra 4b6d43ac54 fix: cleanup 2025-02-11 13:15:46 +05:30
Apoorv Mishra dff6f2b553 fix: move deco application code to plugin 2025-02-11 13:10:04 +05:30
Apoorv Mishra 6780d17b2b fix: comment about skipping tr when cursor is inside of a heading node 2025-02-11 12:58:11 +05:30
Apoorv Mishra f0f13dbc31 fix: backspacing into a node under heading should unfold it if collapsed 2025-02-10 21:06:19 +05:30
6 changed files with 291 additions and 85 deletions
+2
View File
@@ -91,6 +91,7 @@
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@types/sorted-array-functions": "^1.3.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
"autotrack": "^2.4.1",
@@ -228,6 +229,7 @@
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"sorted-array-functions": "^1.3.0",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
-5
View File
@@ -32,8 +32,3 @@ export default function headingToSlug(node: Node, index = 0) {
}
return `${slugified}-${index}`;
}
export function headingToPersistenceKey(node: Node, id?: string) {
const slug = headingToSlug(node);
return `rme-${id || window?.location.pathname}${slug}`;
}
+30 -49
View File
@@ -10,15 +10,21 @@ import { Command, Plugin, Selection } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import Storage from "../../utils/Storage";
import backspaceToParagraph from "../commands/backspaceToParagraph";
import splitHeading from "../commands/splitHeading";
import toggleBlockType from "../commands/toggleBlockType";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import headingToSlug from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
import { HeadingTracker } from "../plugins/HeadingTracker";
import Node from "./Node";
export enum HeadingLevel {
One = 1,
Two,
Three,
Four,
}
export default class Heading extends Node {
className = "heading-name";
@@ -28,7 +34,9 @@ export default class Heading extends Node {
get defaultOptions() {
return {
levels: [1, 2, 3, 4],
levels: Object.values(HeadingLevel).filter(
(value) => typeof value === "number"
),
collapsed: undefined,
};
}
@@ -135,40 +143,29 @@ export default class Heading extends Node {
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);
const pos = view.posAtDOM(event.currentTarget, 0);
const $pos = view.state.doc.resolve(pos);
const node = view.state.doc.nodeAt($pos.before());
if (node) {
const endOfHeadingPos = result.inside + node.nodeSize;
const $pos = view.state.doc.resolve(endOfHeadingPos);
const collapsed = !node.attrs.collapsed;
if (node) {
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));
}
if (collapsed && view.state.selection.to > $pos.end()) {
// move selection to the end of the collapsed heading
const $end = view.state.doc.resolve($pos.end());
tr.setSelection(Selection.near($end, -1));
}
const transaction = tr.setNodeMarkup(result.inside, undefined, {
...node.attrs,
collapsed,
});
const transaction = tr.setNodeMarkup($pos.before(), undefined, {
...node.attrs,
collapsed,
});
const persistKey = headingToPersistenceKey(node, this.editor.props.id);
view.dispatch(transaction);
if (collapsed) {
Storage.set(persistKey, "collapsed");
} else {
Storage.remove(persistKey);
}
view.dispatch(transaction);
if (hadFocus) {
view.focus();
}
if (hadFocus) {
view.focus();
}
}
};
@@ -274,23 +271,7 @@ export default class Heading extends Node {
},
});
const foldPlugin: Plugin = new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
});
return [foldPlugin, plugin];
return [new HeadingTracker(), plugin];
}
inputRules({ type }: { type: NodeType }) {
+4
View File
@@ -19,6 +19,10 @@ export default abstract class Node extends Extension {
return {};
}
get attrs(): NodeSpec["attrs"] {
return {};
}
get markdownToken(): string {
return "";
}
+242
View File
@@ -0,0 +1,242 @@
import { Node } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import sorted from "sorted-array-functions";
import Heading, { HeadingLevel } from "../nodes/Heading";
import { findBlockNodes } from "../queries/findChildren";
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
/**
* Algorithm for finding which headings to unfold upon backspacing is as follows:
*
* 1. From a given cursor position(after backspacing), of all the headings that came before it,
* find the closest heading corresponding to every level.
* 2. Arrange those headings in a stack, maintaining the invariant that the
* heading at a given position in the stack is closer to the cursor than the one below it,
* and also, is of a level larger than that of the one below it.
* This invariant ensures that the heading at a given position in the stack is the "descendant" of the
* heading that's just below it in the stack.
* 3. Pop out the headings from the stack, one by one and collect the ones which are collapsed.
* 4. Unfold those collapsed headings.
*
* Example:
*
* Consider the following document structure,
* _____________________________________________________________________________
* | H2(c) |
* | This goes under H2 |
* | |
* | H1(c) |
* | This goes under H1 |
* | H3(c) |
* | This goes under H3, which is under H1! |
* | H3'(u) |
* | This goes under H3', which is under H1! |
* | H4(c) |
* | This goes under H4, which is under H3, which, further, is under H1! |
* |_____________________________________________________________________________|
*
* The (c) & (u) denote folded and unfolded states of the heading, respectively.
*
* Now, let's say the cursor lands under H4 upon backspacing. The desired state, after backspacing should be that H1,
* along with its "descendant" H4 end up being unfolded, displaying respective content under them.
*
* So, Let's look at the the closest heading of each level from the cursor,
* Level 1: H1
* Level 2: H2
* Level 3: H3'(and not H3)
* Level 4: H4
*
* And, the stack maintaing the invariant would look like,
*
* | H4 |
* | -- |
* | H3'|
* | -- |
* | H1 |
* ----
*
* * Level of H4(4) > Level of H3'(3) && H4 is closer to the cursor than H3'
* * Level of H3'(3) > Level of H1(1) && H3' is closer to the cursor than H1
*
* Notice that we didn't push H2 onto the stack after H1, because that would violate the invariant considering
* H2 is further away from the cursor than H1, and therefore, is NOT A DESCENDANT of H1.
* So, we don't care about H2's collapsed state in this particular case.
*
* Now, we pop out headings and collect the ones which are collapsed, i.e, H1 and H4. Then, we unfold them.
*/
export class HeadingTracker extends Plugin {
private sortedHeadingPositions: SortedHeadingPositions;
private closestHeadingsStack: Stack<HeadingWithPos>;
private doc: Node;
constructor() {
super({
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((tr) => tr.docChanged)) {
return;
}
this.doc = newState.doc;
this.updateHeadingPositions();
const cursorPos = newState.selection.from;
this.collectPrevClosestHeadingsFrom(cursorPos);
const closestHeading = this.closestHeadingsStack.top();
if (
closestHeading &&
cursorPos > closestHeading.pos.before &&
cursorPos < closestHeading.pos.after
) {
// noop if the cursor lies within a heading node because
// then this plugin overrides the behavior of heading toggle button
// in a way that user can't collapse heading anymore by clicking the toggle button
return;
}
const headingsToUnfold = this.findHeadingsToUnfold();
let transaction = newState.tr;
for (const heading of headingsToUnfold) {
transaction = transaction.setNodeMarkup(
heading.pos.before,
undefined,
{
...heading.node.attrs,
collapsed: false,
}
);
}
return transaction;
},
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
});
}
private updateHeadingPositions() {
this.sortedHeadingPositions = new SortedHeadingPositions();
const blocks = findBlockNodes(this.doc);
for (const block of blocks) {
if (block.node.type.name === "heading") {
const $pos = this.doc.resolve(block.pos + 1);
this.sortedHeadingPositions.add(
{ before: $pos.before(), after: $pos.after() },
block.node.attrs.level
);
}
}
}
private findPrevHeadingFrom(
pos: number,
level: number
): HeadingWithPos | null {
const prevHeadingPos = this.sortedHeadingPositions.prev(pos, level);
const prevHeading = (
prevHeadingPos ? this.doc.nodeAt(prevHeadingPos.before) : null
) as Heading | null;
return prevHeading ? { node: prevHeading, pos: prevHeadingPos! } : null;
}
private collectPrevClosestHeadingsFrom(pos: number) {
this.closestHeadingsStack = new Stack();
for (let level = 1; level <= HeadingLevel.Four; level++) {
const heading = this.findPrevHeadingFrom(pos, level);
if (heading) {
if (this.closestHeadingsStack.isEmpty()) {
this.closestHeadingsStack.push(heading);
} else {
const closestHeadingSoFar = this.closestHeadingsStack.top();
if (heading.pos.after > closestHeadingSoFar!.pos.after) {
this.closestHeadingsStack.push(heading);
}
}
}
}
}
private findHeadingsToUnfold() {
const headings: HeadingWithPos[] = [];
while (!this.closestHeadingsStack.isEmpty()) {
const heading = this.closestHeadingsStack.pop();
if (heading && heading.node.attrs?.collapsed) {
headings.push(heading);
}
}
return headings;
}
}
class SortedHeadingPositions {
private sortedPositionsByLevel: Map<number, HeadingPos[]>;
private cmpFn = (a: HeadingPos, b: HeadingPos) => {
if (a.before === b.before) {
return 0;
}
return a.before < b.before ? -1 : 1;
};
constructor() {
this.sortedPositionsByLevel = new Map();
for (let l = 1; l <= HeadingLevel.Four; l++) {
this.sortedPositionsByLevel.set(l, []);
}
}
public add(pos: HeadingPos, level: number) {
sorted.add(this.sortedPositionsByLevel.get(level)!, pos, this.cmpFn);
}
public prev(pos: number, level: number): HeadingPos | null {
const prevIndex = sorted.lt(
this.sortedPositionsByLevel.get(level)!,
{ after: pos, before: pos },
this.cmpFn
);
return prevIndex !== -1
? this.sortedPositionsByLevel.get(level)![prevIndex]
: null;
}
}
export class Stack<T> {
private items: T[] = [];
public push(item: T): void {
this.items.push(item);
}
public pop(): T | undefined {
return this.items.pop();
}
public top(): T | undefined {
return this.items[this.items.length - 1];
}
public isEmpty(): boolean {
return this.items.length === 0;
}
}
type HeadingPos = {
before: number;
after: number;
};
type HeadingWithPos = {
node: Heading;
pos: HeadingPos;
};
+13 -31
View File
@@ -768,16 +768,7 @@
"@babel/traverse" "^7.25.9"
semver "^6.3.1"
"@babel/helper-create-regexp-features-plugin@^7.18.6":
version "7.25.7"
resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz#dcb464f0e2cdfe0c25cc2a0a59c37ab940ce894e"
integrity sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ==
dependencies:
"@babel/helper-annotate-as-pure" "^7.25.7"
regexpu-core "^6.1.1"
semver "^6.3.1"
"@babel/helper-create-regexp-features-plugin@^7.25.9":
"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.25.9":
version "7.26.3"
resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz#5169756ecbe1d95f7866b90bb555b022595302a0"
integrity sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==
@@ -1078,20 +1069,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-syntax-typescript@^7.25.9":
"@babel/plugin-syntax-typescript@^7.25.9", "@babel/plugin-syntax-typescript@^7.7.2":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz#67dda2b74da43727cf21d46cf9afef23f4365399"
integrity sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==
dependencies:
"@babel/helper-plugin-utils" "^7.25.9"
"@babel/plugin-syntax-typescript@^7.7.2":
version "7.24.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz#b3bcc51f396d15f3591683f90239de143c076844"
integrity sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==
dependencies:
"@babel/helper-plugin-utils" "^7.24.0"
"@babel/plugin-syntax-unicode-sets-regex@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357"
@@ -5238,6 +5222,11 @@
resolved "https://registry.yarnpkg.com/@types/slug/-/slug-5.0.7.tgz#dc0e5d3ea6ab6daf29bafaa2bcf689cfefaab28a"
integrity "sha1-3A5dPqarba8puvqivPaJz++qsoo= sha512-u5T21+PqfRpHszRWSv5gJYDIkm6lmYUXLjZVYvQyWNO9aC0zs+a11qqtbxbsNRiNLTmw0Xi+5NWK/Qz+JjaB9w=="
"@types/sorted-array-functions@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@types/sorted-array-functions/-/sorted-array-functions-1.3.3.tgz#c7e9902b4677dfd0693e4658d85a3ccb35b6e091"
integrity sha512-Znd+o08cWzYInbmSyoeK7D1aet3zFj346zuL0QWAnKVg3Sp57azvCzbxBfnxjDPNyHqOhzvK79K9sEV97BX5iQ==
"@types/stack-utils@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
@@ -13519,19 +13508,7 @@ regexpp@^3.0.0:
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
integrity "sha1-BCWido2PI7rXDKS5BGH6LxIT4bI= sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg=="
regexpu-core@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.1.1.tgz#b469b245594cb2d088ceebc6369dceb8c00becac"
integrity sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==
dependencies:
regenerate "^1.4.2"
regenerate-unicode-properties "^10.2.0"
regjsgen "^0.8.0"
regjsparser "^0.11.0"
unicode-match-property-ecmascript "^2.0.0"
unicode-match-property-value-ecmascript "^2.1.0"
regexpu-core@^6.2.0:
regexpu-core@^6.1.1, regexpu-core@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.2.0.tgz#0e5190d79e542bf294955dccabae04d3c7d53826"
integrity sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==
@@ -14231,6 +14208,11 @@ sort-keys@^5.0.0:
dependencies:
is-plain-obj "^4.0.0"
sorted-array-functions@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5"
integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==
source-list-map@~0.1.7:
version "0.1.8"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106"